Webhooks

relay hands inbound email to your application over HTTPS. Deliveries follow
the Standard Webhooks specification, one
signing secret per webhook, flat JSON payloads, and a defined retry
schedule. This page is the reference for everything on the wire.

Structure

A webhook belongs to a receiving domain, matches recipient addresses with a
glob pattern, and points at one HTTPS URL:

  • Receiving domain. The domain whose MX points at the relay MX
    hostnames, for example app.acme.com.
  • Address pattern. A glob over the recipient address, for example
    *@app.acme.com, support@acme.com, or bill+*@acme.com. A pattern is
    optional. A webhook without a pattern receives everything for its domain.
  • URL. Must use HTTPS.
  • One signing secret per webhook. Each webhook has its own secret in
    whsec_ form. Copy it from the dashboard and use it to verify deliveries.

The payload

The body is flat JSON, and the raw message body is never inlined:

{
  "type": "email.received",
  "message_id": "0198b7c2-7c11-7000-8000-3b9bb91234af",
  "sender": "ann@example.net",
  "recipient": "kim@app.acme.com",
  "subject": "Quote request",
  "rfc822_message_id": "<ab12@example.net>",
  "received_with_tls": true,
  "receiving_domain": "app.acme.com",
  "body_url": "https://storage.relays.to/messages/0199....eml?signature=...",
  "spam_score": 1.5,
  "spam_action": "no action",
  "received_at": "2026-08-29T09:41:18Z"
}

message_id is the relay message id. spam_score and spam_action carry
the scan result. Test deliveries (from the dashboard button) use type
email.test, with most fields empty, so you can build your endpoint before
real mail arrives.

Delivery headers

relay signs each delivery and sends four headers:

Header Value Example
webhook-id msg_<uuidv7>, unique per delivery msg_0199ac21...
webhook-timestamp Unix seconds at signing 1787986278
webhook-signature v1,<base64 HMAC-SHA256 signature> v1,2lR0...
Content-Type application/json

The signed content is {id}.{timestamp}.{body}. The signature is an
HMAC-SHA256 signature of that byte string with the webhook’s signing secret.

Verify a delivery

Do not trust deliveries without verification. Verify every delivery with the
webhook’s signing secret. Any Standard Webhooks SDK does the job:

import json

from standardwebhooks import Webhook

wh = Webhook(webhook_signing_secret)  # whsec_... string


def handler(payload: bytes, headers: dict[str, str]) -> None:
    event = wh.verify(payload, headers)  # raises on a bad signature
    body = json.loads(payload)
    if body["type"] == "email.received":
        fetch(body["body_url"])  # signed download URL for the raw message

SDKs with the same semantics exist for Node, Go, Ruby, Elixir, Rust, and
more. Verification with wh.verify checks the timestamp for you, so replayed
deliveries fail as well.

The delivery record

relay stores each POST attempt as a webhook delivery: the target URL, the
response code, the response body excerpt (2,000 characters), and the
delivery status. The dashboard shows these per inbound message, so every
delivery is auditable afterwards.

Timeout and retry

relay allows 30 seconds per POST and expects a 2xx answer for success.
Deliveries follow the Standard Webhooks schedule, with a random 0 to 29
seconds of jitter added to each delay:

Attempt Delay Approximate total
1 immediate 0 s
2 5 s 5 s
3 5 min 5 min
4 30 min 35 min
5 2 h 2 h 35 min
6 5 h 7 h
7 10 h 17 h
8 14 h 31 h
9 20 h 51 h
10 24 h 75 h, about 3 days

Ten attempts, spread over roughly three days, with jitter on every delay.
If no attempt ever succeeds, relay marks the message as
webhook_failed. Successful deliveries mark the message as webhook_sent.

Handling and re-delivery

A webhook that answers 2xx within the timeout is done. Anything else
(connection refusal, 4xx, 5xx, timeout) is a failure and retries on the
schedule above. Handle deliveries idempotently, and dedupe on webhook-id, because
relay can retry deliveries.
Trigger test deliveries with email.test from the dashboard without waiting
for real inbound mail.

Security model

The signing secret stays encrypted in the relay database and appears in the
dashboard for you to copy. It changes only when you create a new webhook.
Signatures cover the webhook id, timestamp, and body, so your endpoint
verifies both authenticity and freshness.

A complete receiving endpoint

FastAPI example:

from fastapi import FastAPI, Request, HTTPException
from standardwebhooks import Webhook

app = FastAPI()
wh = Webhook(WEBHOOK_SIGNING_SECRET)


@app.post("/mail")
async def mail(request: Request):
    payload = await request.body()
    try:
        event = wh.verify(payload, dict(request.headers))
    except Exception:
        raise HTTPException(401, "bad signature")
    try:
        process(json.loads(payload))
    finally:
        return {"ok": True}

Return 2xx on accepted event, so relay stops retrying. Do the slow work
afterwards. Answer asynchronously where possible.

  • Receiving. What
    qualifies a message for webhooks.
  • Security. Key
    storage and rotation.