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_html → text/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: 2xx → sent, 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: 2xx → sent 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¶
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_keyis 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.severityuses the same vocabulary as the Slack adapter —CRITICAL/WARNING/OK/INFO, mapped to PagerDuty'scritical/warning/info/info; absent meanserror.- 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.