Delivery Webhooks
Get delivery status pushed to your application the moment it changes, in any language — no polling, no client library.
How it works
You register one HTTPS URL. Every time a delivery changes state, RelayGrid POSTs a signed JSON event to it. The delivery object inside the event is byte-for-byte what GET /api/v1/deliveries/:id returns, so any code you already wrote against the polling API works on webhook events unchanged.
Registering your endpoint
In the dashboard, go to Settings → Server Configuration and fill in the Webhook URL field. Saving it registers the endpoint and shows your signing secret once — copy it then, because list and show responses only ever return a preview of it. If you lose it, rotate to get a new one.
Or do the same over the API:
curl -X POST https://relaygrid.dev/api/v1/webhook_endpoints \
-H "Authorization: Bearer sk_your_key" \
-H "Content-Type: application/json" \
-d '{"webhook_endpoint": {"url": "https://your-app.com/webhooks/relaygrid"}}'
# 201 Created
# { "webhook_endpoint": { "id": 1, "url": "...", "active": true,
# "secret": "whsec_..." } }URL requirements
- HTTPS only. Plain HTTP is rejected.
- The host must resolve to a publicly routable address. Loopback, private, link-local, CGNAT and reserved ranges are refused, as are hosts that are internal by name (
localhost,*.local,*.internal,*.home.arpa). This is re-checked immediately before every POST, not just when you save. - Maximum 2048 characters.
- One endpoint per account. Creating a second returns
409 Conflict— update the existing one instead, or delete it and re-create.
Event types
There is nothing to subscribe to. A registered endpoint receives every event type, including ones added in future releases, so switch on the event's type and ignore what you don't handle.
delivery.scheduledA scheduled send created the delivery, at schedule time — not dispatch. Carries the delivery snapshot. The same delivery later emits delivery.queued when the sweep actually dispatches it.
delivery.queuedA send created the delivery. One per channel on the template. A scheduled delivery emits this again at dispatch time, after having already emitted delivery.scheduled at schedule time.
delivery.sentRelayGrid handed the notification to the provider (SendGrid, the push service) without error.
delivery.deliveredThe provider confirmed the notification reached the recipient.
delivery.openedThe recipient opened the notification — an email open pixel fired, or a push message was marked seen.
delivery.failedThe delivery failed for good, after RelayGrid exhausted its own send retries. Carries friendly_error_message.
delivery.bouncedThe provider reported a bounce for the recipient address.
delivery.canceledA scheduled delivery was canceled before dispatch. error_message names the reason: requested (the caller canceled the schedule), recipient_deleted, no_live_channels, or channel_deleted_or_detached (only that channel was removed; the rest of the message still dispatches).
pingThe test event from the dashboard's "Send test event" button, or POST /webhook_endpoints/:id/test. Carries no delivery.
There is no event for a delivery being retried
When a send fails but RelayGrid will try again, the delivery moves to the retrying status and emits nothing. You are only told about a failure once it is final, so delivery.failed is safe to page on. A delivery that recovers on a later attempt sends you delivery.sent and no failure at all.
The request
Every event is a POST with a JSON body and these headers:
X-RelayGrid-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>Verify this before you trust the body. See below.
X-RelayGrid-EventThe event type, e.g. delivery.deliveredConvenience only — the same value is in the body as "type". Route on the verified body, not on this header.
X-RelayGrid-Event-Idevt_...Matches "id" in the body. Stable across retries, so it is what you deduplicate on.
Content-Typeapplication/jsonThe body is always a single JSON object.
User-AgentRelayGrid-Webhooks/1.0Useful for filtering your access logs. Not an authentication signal — anyone can send it.
Event Envelope
{
"id": "evt_9f2c4a1b8d3e5f60718293a4b5c6d7e8",
"type": "delivery.delivered",
"created_at": "2026-08-06T17:03:11Z",
"data": {
"delivery": {
"id": 201,
"status": "delivered",
"error_message": null,
"sent_at": "2026-08-06T17:03:04.118Z",
"failed_at": null,
"delivered_at": "2026-08-06T17:03:11.402Z",
"opened_at": null,
"created_at": "2026-08-06T17:03:02.771Z",
"updated_at": "2026-08-06T17:03:11.402Z",
"message_id": 87,
"friendly_error_message": null,
"limit_feature_key": null,
"notification_channel": {
"id": 4,
"name": "Transactional Email",
"channel_type": "email"
}
}
}
}data.delivery is present on every delivery.* event. A ping instead carries { "message": "..." }, so guard on the event type before reaching for a delivery.
One shape worth knowing: delivery.delivered for a message the recipient had already opened carries status: "opened", because being opened is further along than being delivered and the status never moves backwards. Branch on the status in the payload rather than assuming it matches the event name.
Verifying the signature
Your endpoint is a public URL, so anyone can POST to it. The signature is what proves an event came from RelayGrid. Verify it before you parse or trust anything in the body.
X-RelayGrid-Signature: t=1754499791,v1=5e1b8c...
- Split the header on
,to gett(unix seconds) and one or morev1values. - Build the signed string as
"<t>." + raw_body— the timestamp, a literal dot, then the request body exactly as received. - Compute
HMAC-SHA256of that string with your signing secret and hex-encode it. - Compare against each
v1value using a constant-time comparison. A plain==returns early on the first differing byte and leaks, through its own timing, how much of a guess was right. - Reject the request if
tis more than five minutes from your clock. The timestamp is signed with the body, not merely sent beside it, which is what bounds how long a captured request stays replayable.
Sign the raw bytes, not your parsed body
The MAC covers the exact bytes RelayGrid sent. If your framework parses JSON before your handler runs and you re-serialize it to verify, key order and whitespace will differ and the signature will never match. Most frameworks need explicit configuration to hand you the raw body on this route.
Node.js (Express)
const crypto = require("crypto");
const TOLERANCE = 300; // seconds
const SECRET = process.env.RELAYGRID_WEBHOOK_SECRET;
// express.raw, not express.json: the signature covers the exact bytes we sent,
// so a body that has been parsed and re-serialized will not verify.
app.post("/webhooks/relaygrid", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-RelayGrid-Signature") || "";
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=", 2))
);
const timestamp = Number(parts.t);
const expected = crypto
.createHmac("sha256", SECRET)
.update(timestamp + "." + req.body.toString("utf8"))
.digest("hex");
const received = Buffer.from(parts.v1 || "", "utf8");
const computed = Buffer.from(expected, "utf8");
const signatureOk =
received.length === computed.length && crypto.timingSafeEqual(received, computed);
const freshEnough = Math.abs(Date.now() / 1000 - timestamp) <= TOLERANCE;
if (!signatureOk || !freshEnough) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
enqueueForProcessing(event); // do the real work off the request
res.sendStatus(200);
});Python (Flask)
import hashlib, hmac, json, os, time
from flask import request, abort
TOLERANCE = 300 # seconds
SECRET = os.environ["RELAYGRID_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/relaygrid")
def relaygrid_webhook():
header = request.headers.get("X-RelayGrid-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp = parts.get("t", "")
body = request.get_data() # raw bytes, before any parsing
expected = hmac.new(
SECRET, timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
if abs(time.time() - int(timestamp)) > TOLERANCE:
abort(400)
event = json.loads(body)
enqueue_for_processing(event) # do the real work off the request
return "", 200Using Ruby?
Don't write any of the above. The relaygrid gem ships RelayGrid::Webhooks.construct_event, which does the whole verification and hands you a typed event. See the Ruby Gem documentation.
Rotating the secret
POST /api/v1/webhook_endpoints/:id/rotate_secret (or the rotate button in the dashboard) issues a new secret and returns it in full. The old secret stops working immediately, so deploy the new one to your receiver first. Verification code should accept multiple v1 values from the header — the format allows for more than one so a future rotation can overlap two secrets without dropping events.
Responding, retries and failures
Answer with any 2xx as soon as you have stored the event. RelayGrid allows 5 seconds to connect and 10 seconds to respond; do the real work in a background job rather than inside the request, or a slow handler turns into a retry storm.
2xx — success
The event is done and the endpoint's failure count resets to zero.
5xx, timeouts, DNS and TLS errors, 408, 429 — retried
Up to 8 attempts per event with a growing backoff, spread over several hours. A receiver that is down for a deploy loses nothing.
Other 4xx — not retried
A 404 or 410 is a wrong URL, not an outage, so retrying it can't help. The attempt is logged and counts toward auto-disable. Note that this includes 401 and 403 — don't put your webhook route behind application auth, since the signature is the authentication.
Auto-disable after 20 consecutive failed events
An endpoint that fails 20 events in a row — each having exhausted its retries — is switched off, recorded in your audit log, and flagged in the dashboard with a re-enable button. Deliveries themselves are unaffected; only the notifications about them stop. Re-enabling (or POST /webhook_endpoints/:id/reactivate) clears the failure count, so a fixed receiver gets a full 20 again rather than being disabled by its next hiccup. Events emitted while an endpoint is disabled are not replayed when you turn it back on — use the delivery endpoints to backfill.
Delivery guarantees
At-least-once
The same event can arrive more than once — a receiver that times out after doing its work still gets retried. Key your side effects off the event id, which is stable across retries.
Unordered
delivery.delivered can reach you before delivery.sent. Branch on data.delivery.status, which is the state at emission time, and never advance your own record backwards on a late arrival.
Testing and debugging
Use Send test event in the dashboard (or POST /api/v1/webhook_endpoints/:id/test) to queue a signed ping. It goes through the same signing and dispatch path as a real event, so it proves the URL, the TLS certificate and your verification code all work before any real traffic depends on them. Dispatch is asynchronous — the call returns an event id, not a result.
Then read the attempt log, which is the answer to "why didn't my webhook arrive?":
curl https://relaygrid.dev/api/v1/webhook_endpoints/1/attempts \
-H "Authorization: Bearer sk_your_key"
# { "attempts": [ { "event_id": "evt_...", "event_type": "ping",
# "attempt_number": 1, "succeeded": true,
# "response_code": 200, "error_message": null,
# "duration_ms": 143, "created_at": "..." } ] }Every attempt is recorded with the response code your server actually returned, the transport error if the request never completed, and how long it took. The endpoint returns the 50 most recent; the dashboard renders the same log under the Webhook URL field. Events and their attempts are pruned after 30 days.
localhost will not work in development
Endpoint URLs must be public HTTPS, so http://localhost:3000 is rejected at save time and a host that resolves to a private address is refused again at dispatch. Point the endpoint at a tunnel (ngrok, Cloudflare Tunnel, or similar) while developing, and keep a way to swap the URL back for production.
Managing endpoints over the API
Everything the dashboard does is available with your API key. Full parameter reference lives under Webhook Endpoints in the REST API documentation.
GET /api/v1/webhook_endpoints # your endpoint (0 or 1 entries) POST /api/v1/webhook_endpoints # register; returns the secret PATCH /api/v1/webhook_endpoints/:id # change the url, or pause with active: false DELETE /api/v1/webhook_endpoints/:id # stop sending events POST /api/v1/webhook_endpoints/:id/rotate_secret POST /api/v1/webhook_endpoints/:id/reactivate POST /api/v1/webhook_endpoints/:id/test GET /api/v1/webhook_endpoints/:id/attempts