Skip to content

Sending to a Webhook

The webhook transport posts to any HTTP API — SendGrid, Slack, Teams, an internal service, or anything else that accepts a JSON body over HTTP (and, via body_style: "form", APIs that take only form-encoded bodies, such as Twilio's). Its defining property is that the Processor understands neither the request nor the response: it posts message to profile.url exactly as pg_relay_notifier gave it, with no interpretation of its shape, and it asks pg_relay_notifier's own pgrelay_notifier.classify_webhook_response function to make sense of whatever comes back. Adding support for a new provider under this transport is entirely pg_relay_notifier's job — it needs zero changes to the Processor.

Profile fields

Field Type Required Default Notes
url string yes the endpoint to POST (or otherwise call)
method string no POST
body_style string no json json sends message as a JSON body; form re-encodes a flat message as application/x-www-form-urlencoded — see Form-encoded bodies
body_merge object no top-level keys overlaid onto message before encoding, producer values overwritten — see Merging profile keys into the body
auth object yes see below — there is no "no authentication" option
headers object no flat map of header name to header value, merged into the request
timeout_seconds integer no 30 (capped at 120)

Content-Type defaults to application/json (or to application/x-www-form-urlencoded under body_style: "form"), but a Content-Type entry in headers overrides it (matched case-insensitively) — useful for a provider that insists on a charset suffix.

The auth block

auth.style Required sub-fields What it does
bearer_header secret sets Authorization: Bearer <secret>
custom_header header_name, secret sets the named header to secret
basic_auth username, secret sets HTTP Basic authentication

auth.secret (and auth.username, where applicable) accept _env: references — see Keeping Secrets Out of the Database. A missing or unrecognised auth.style, or a required sub-field left out for the style you chose, is a permanent failure resolved before any request is sent at all — classify_webhook_response is never called for this case, because there is nothing for it to interpret.

The message

Unlike the other four transports, webhook has no fixed message shape of its own. message is expected to already be the complete, provider-correct request body — pg_relay_notifier builds it however the target API needs, and the Processor sends it uninterpreted, with no to/subject/body_text structure imposed on it. Under the default body_style it goes on the wire verbatim as json.Marshal(message) — with exactly one exception, the profile's own body_merge block.

Merging profile keys into the body

Some APIs authenticate inside the request body rather than in a header or the URL — PagerDuty's Events API expects a routing_key field in the JSON it receives; Vonage's legacy SMS API wants api_key and api_secret. That is a problem for the secrets model: _env: resolution deliberately never touches message, because messages are producer-authored content, and resolving environment references in content would let anyone able to queue an event read the Processor's secrets.

body_merge closes the gap from the trusted side. It is a profile field — authored by whoever configures profiles, like every other secret-bearing setting — whose top-level keys the Processor overlays onto the message immediately before encoding:

// profile
{
  "url": "https://events.pagerduty.com/v2/enqueue",
  "auth": {"style": "bearer_header", "secret": "unused"},
  "body_merge": {"routing_key": "_env:PD_ROUTING_KEY"}
}

Three properties make this safe where message-side resolution would not be:

  • The values live in the profile, so _env: references in them resolve exactly like auth.secret does — the credential stays in the environment, never in the database, and an unset variable is the same permanent, clearly-named error.
  • A merged key always overwrites whatever the producer put in message under the same name. That is the security property, not a convenience: the key is profile-owned, and a producer has no way to inject, spoof, or redirect it.
  • The overlay is shape-blind — top-level key assignment only, no inspection of the rest of the message. The transport still interprets nothing.

The merge works identically under both body styles (under form, merged values follow the same flat-scalar encoding rules as everything else). A body_merge that is not a JSON object is a permanent failure with no request sent, mirroring the malformed-auth rule.

Not just secrets: enforcing a field

Because a merged key always wins, body_merge doubles as an enforcement mechanism: any body field the profile owner wants to make non-negotiable can be pinned there, secret or not. The canonical example is pinning a Slack profile to exactly one channel:

// profile — every message through this profile lands in #ops-alerts, full stop
{
  "url": "https://slack.com/api/chat.postMessage",
  "auth": {"style": "bearer_header", "secret": "_env:SLACK_BOT_TOKEN"},
  "body_merge": {"channel": "C0XXXXXXX"}
}

Whatever channel the producer names — a typo, a copy-pasted id from another environment, or a deliberate attempt to redirect the message — the profile's value replaces it before the request is built. Routing becomes a property of the profile, controlled by whoever configures profiles, instead of a courtesy the producer is trusted to get right. The same pattern pins any provider's routing-shaped field: a fixed source identifier, an API version, a tenant discriminator. Values may be literals (as above) or _env: references — use a reference when the value differs per environment (the dev profile and the prod profile carry the same JSON, and the host's environment decides the channel).

Form-encoded bodies

Not every HTTP API accepts JSON: some — Twilio's and Telesign's SMS endpoints are the prominent examples — take only application/x-www-form-urlencoded request bodies. Setting "body_style": "form" in the profile has the Processor re-encode message as form pairs instead of JSON. This changes only the wire encoding, never the meaning: every key and value passes through unchanged, exactly as under JSON.

The encoding rules are the obvious ones, and nothing more:

  • strings go through verbatim;
  • numbers and booleans render in their canonical JSON text form (3, 1.5, true);
  • a null field is omitted;
  • an array of scalars repeats its key once per element — the standard form convention for multi-valued fields (Twilio's MediaUrl, for instance);
  • a nested object, or an array containing anything but scalars, is a permanent failure before any request is sent. Form encoding has no standard way to express nesting, and the webhook transport refuses to invent one (bracket syntax, JSON-stuffed-into-a-field) — that would be exactly the kind of interpretation this transport exists to avoid. A message for a form-encoded API must be flat.

An unrecognised body_style is likewise a permanent failure with no request sent, mirroring the malformed-auth rule below. Everything else about the transport — the auth block, redirect refusal, classify_webhook_response, timeouts — behaves identically under either style.

Interpreting the response

Whatever the target returns, the Processor hands it to pgrelay_notifier.classify_webhook_response(pk, http_status, response_headers, response_body) and does exactly what that function says:

  • sent — the send is recorded as successful.
  • retry — treated as transient, goes through the normal retry chain.
  • failed — a permanent failure, resolved immediately with no retry.

Response headers are flattened into a single JSON object with lower-cased keys before this call, so classify_webhook_response can reliably look up something like x-message-id regardless of how the provider happened to capitalise it; a header sent with multiple values is joined with commas.

If classify_webhook_response raises an error, is missing entirely, returns no rows, or returns a status other than sent/retry/failed, the Processor treats that exactly the way it treats a broken fetch() call — as a permanent failure. This is not a special case invented for webhooks; it is the same rule applied consistently.

Redirects are never followed

A 3xx response is resolved as a permanent failure by the Processor itself, without ever calling classify_webhook_response. The Processor does not follow the redirect, because doing so would mean silently forwarding the request — including its resolved secret — to wherever the provider's response happens to point. If you see this failure, the fix is to correct profile.url, not to expect pg_relay to chase the redirect for you.

When no response comes back at all

A genuine network failure — the request never reaches the server: DNS, TLS, connection refused, or a timeout — is still handed to classify_webhook_response, with http_status set to 0. By convention, a well-behaved classify_webhook_response treats http_status = 0 as transient every time, since nothing about the request itself was rejected — there was simply no response to judge it by.

This is a different situation from the malformed-auth case above: there, no request is ever sent, and classify_webhook_response is never called at all.


Continue to the provider recipes — worked webhook (and SMTP) configurations for SendGrid, Postmark, SMTP2GO, Resend, Slack, Mattermost, Microsoft Teams, the SMS API Providers, and the Incident Alerting Platforms — starting with Amazon SES.