Skip to content

Resend, Telnyx, and PagerDuty Endpoints

Three more webhook provider adapters ship alongside Slack, covering the three shapes a webhook provider can take: Resend (email-shaped — the notification's ordinary columns map onto a REST body), Telnyx (SMS — one notification, one message, provider-native recipients), and PagerDuty (incident alerting — lifecycle events with deduplication built in). As with Slack, all provider knowledge lives in this extension's SQL: the Processor POSTs what the adapter renders and hands back the response for the adapter to classify.

Everything on this page follows the same rules: p_provider selects the adapter at create_profile() (it cannot change later), secrets are _env: references, timeout_seconds defaults to 30 (cap 120), retries follow the channel's retry policy, and the vendor owns their endpoints and response shapes — the usual qualification applies: verify against the vendor's current documentation.

Resend

SELECT pgrelay_notifier.create_profile(
    p_profile_name => 'resend_api',
    p_transport    => 'webhook',
    p_provider     => 'resend',
    p_profile      => '{
        "url":  "https://api.resend.com/emails",
        "auth": {"style": "bearer_header", "secret": "_env:RESEND_API_KEY"}
    }'::jsonb,
    p_channel      => 'notifications',
    p_send_from    => '[email protected]'   -- Resend requires a from address
);

Senders use ordinary send()/compose() calls with email addresses as recipients — unlike other webhook providers, Resend's recipients are validated for email shape exactly as SMTP's are. The adapter maps the notification straight onto Resend's fields: sender → from, recipients → to/cc/bcc, subject, body_text/body_htmltext/html, and attachments → Resend's {filename, content, content_type} array. Two things Resend requires that compose() therefore enforces up front: a sender (pass p_sender, or set the profile's p_send_from default as above) and a subject.

p_payload carries pass-along Resend arguments — headers (an object), tags ([{"name", "value"}, ...]), scheduled_at — merged into the body last. Anything else is rejected at compose time as a typo.

Classification: 2xxsent, with the body's id as provider_ref; 429 (Resend rate-limits per second — routine on a burst) / 5xx / no response → retry; any other 4xx → failed with Resend's error name and message in status_detail (an unverified sending domain is the classic first failure).

Telnyx (SMS)

SELECT pgrelay_notifier.create_profile(
    p_profile_name => 'telnyx_sms',
    p_transport    => 'webhook',
    p_provider     => 'telnyx',
    p_profile      => '{
        "url":  "https://api.telnyx.com/v2/messages",
        "auth": {"style": "bearer_header", "secret": "_env:TELNYX_API_KEY"}
    }'::jsonb,
    p_channel      => 'notifications',
    p_send_from    => '+61480000000'   -- the sending number (E.164)
);

One notification is one SMS. The single recipient is the destination number (compose() enforces exactly one — loop for multiple destinations), the sender is the sending number (p_sender or the profile's send_from default, which accepts a phone number here — the email-shape check applies only to email-shaped providers), and the text is body_text, else the subject, else body_html. Alternatively, route via a Telnyx messaging profile instead of a fixed number: p_payload => '{"messaging_profile_id": "..."}' — then no sending number is needed. media_urls (an array of http(s) URLs) is the other pass-along payload key, for MMS.

Classification: 2xxsent with data.id as provider_ref; 429/5xx/no response → retry; other 4xx → failed with the first element of Telnyx's errors[] as the detail.

Two doctrine points inherited from the SMS chapter: "sent" means Telnyx accepted the message — final delivery (the DLR) arrives only via callback webhooks, which pg_relay deliberately has no listener for, so reconcile delivery in the Telnyx console using the recorded id; and phone numbers are message content — the adapter never puts them in detail text (though Telnyx's own error text may echo one, which passes through as the provider's words).

PagerDuty

export PD_ROUTING_KEY=R0XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX   # Processor host
SELECT pgrelay_notifier.create_profile(
    p_profile_name => 'pagerduty',
    p_transport    => 'webhook',
    p_provider     => 'pagerduty',
    p_profile      => '{
        "url":  "https://events.pagerduty.com/v2/enqueue",
        "auth": {"style": "bearer_header", "secret": "unused"},
        "body_merge": {"routing_key": "_env:PD_ROUTING_KEY"}
    }'::jsonb,
    p_channel      => 'alerts'
);

Two things are special here, both enforced by create_profile(). PagerDuty's Events API authenticates inside the request body, so the routing key rides the profile's body_merge overlay as an _env: reference — required, never literal, never rendered into message data (a routing_key in p_payload is rejected outright). And since the API ignores the Authorization header, auth.secret carries the literal placeholder 'unused' — the one non-_env: value webhook validation accepts, for exactly this arrangement.

Sending a trigger event:

SELECT pgrelay_notifier.send('pagerduty',
    ARRAY['relay_dev'],                        -- the recipient = payload.source (what is alerting)
    'disk alert: volume is filling',           -- subject → payload.summary
    'tablespace at 91%',                       -- body → custom_details.details
    p_payload => '{"severity": "CRITICAL", "component": "storage"}'::jsonb);
  • dedup_key is derived from the notification id (pgrelay-<id>) unless the payload overrides it. This turns pg_relay's at-least-once delivery into exactly-one-alert: a redelivered event updates the alert it already created instead of paging twice.
  • severity uses the same vocabulary as the Slack adapter — CRITICAL/WARNING/OK/INFO, mapped to PagerDuty's critical/warning/info/info; absent means error.
  • Other payload keys: source (overrides the recipient), component, group, class, timestamp, custom_details (object), links, images, client, client_url.

The alert's lifecycle is driven from the database with the same dedup key:

-- Resolve the alert the trigger above created (dedup_key = 'pgrelay-<its id>'):
SELECT pgrelay_notifier.send('pagerduty', ARRAY['relay_dev'], NULL, NULL,
    p_payload => '{"event_action": "resolve", "dedup_key": "pgrelay-84840233236955255"}'::jsonb);

acknowledge and resolve events are rendered as the minimal envelope the API specifies, and compose() requires the explicit dedup_key for them — a fresh notification's derived key could never match the trigger it targets.

Classification: 2xx (the API answers 202 Accepted) → sent with the echoed dedup_key as provider_ref — acceptance is the hand-off; escalation and reaching a human are PagerDuty's job from that moment; 429/5xx/no response → retry; other 4xx → failed with PagerDuty's message and errors[] in the detail (a rejected routing key, an invalid event).

Shared operational notes

  • Rotating any key is an environment change on the Processor host; the Processor reads its environment at start, so restart it after changing a variable's value under systemd (see SMTP Endpoints).
  • Debug tracing works identically to every transport: p_debug => true (or the profile default) writes the step trace, including the observed HTTP status.
  • As with the Slack recipe when it shipped, the Resend, Telnyx, and PagerDuty adapters are documentation-verified against each vendor's current API docs — run one real send per provider (a test mailbox, your own number, a test PagerDuty service) before relying on a profile in production.