RelayGrid logoRelayGrid

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.scheduled

A 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.queued

A 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.sent

RelayGrid handed the notification to the provider (SendGrid, the push service) without error.

delivery.delivered

The provider confirmed the notification reached the recipient.

delivery.opened

The recipient opened the notification — an email open pixel fired, or a push message was marked seen.

delivery.failed

The delivery failed for good, after RelayGrid exhausted its own send retries. Carries friendly_error_message.

delivery.bounced

The provider reported a bounce for the recipient address.

delivery.canceled

A 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).

ping

The 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.delivered

Convenience 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/json

The body is always a single JSON object.

User-AgentRelayGrid-Webhooks/1.0

Useful 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...
  1. Split the header on , to get t (unix seconds) and one or more v1 values.
  2. Build the signed string as "<t>." + raw_body — the timestamp, a literal dot, then the request body exactly as received.
  3. Compute HMAC-SHA256 of that string with your signing secret and hex-encode it.
  4. Compare against each v1 value 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.
  5. Reject the request if t is 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 "", 200

Using 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