DocumentationReceive and verify outbound webhooks

Receive and verify outbound webhooks

What your customers' endpoints receive from the Webhook Relay Outbound Gateway: Standard Webhooks headers, signature verification in Node.js and Go, deduplication on webhook-id, retries, endpoint states and secret rotation.

Each Outbound Gateway delivery is an HTTPS POST with a JSON body, signed using the Standard Webhooks scheme. The receiving endpoint verifies the signature against the raw body, deduplicates on webhook-id, and responds with any 2xx status within the endpoint timeout (15 seconds by default). Share this page with your customers along with their endpoint's signing secret.

What a delivery looks like

POST /webhooks HTTP/1.1
Host: acme.example
Content-Type: application/json
webhook-id: 0199cf5e-7a3c-7d2e-9b1a-3f4e5d6c7b8a
webhook-timestamp: 1791626714
webhook-signature: v1,6UwV1HPN4tp13ssBSQEu7mdFW217e42pcT32bZjJlyc=
webhook-event-type: invoice.paid
webhook-consumer: customer_42

{"invoice_id":"inv_123","amount":4900}
HeaderValue
webhook-idThe message ID. It is the same for every endpoint, every retry and every replay of the message.
webhook-timestampUnix time in seconds of this attempt. Each retry gets a new timestamp and a new signature.
webhook-signatureOne or more space-separated v1,<base64 HMAC-SHA256> signatures. There are two during a secret rotation.
webhook-event-typeThe event type, such as invoice.paid.
webhook-consumerThe consumer ID the endpoint belongs to.

The body is the published JSON payload, or the output of the endpoint's Function if it has one. Any custom headers configured on the endpoint are sent as well. Redirects are not followed.

Verify the signature

The signed content is {webhook-id}.{webhook-timestamp}.{body}, where body is the exact raw bytes received. The key is the base64-decoded part of the endpoint secret after the whsec_ prefix. Compute HMAC-SHA256, base64-encode it, and compare it in constant time against each v1, entry in webhook-signature. Also reject timestamps that are too far from the current time, so captured requests can't be replayed later.

Parsing and re-serializing the JSON before verifying changes the bytes and breaks the signature. Read the raw body first.

Node.js

import crypto from "node:crypto";

// rawBody: the exact bytes received (Buffer or string). secret: "whsec_…".
export function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!id || !timestamp || !signatureHeader) return false;

  // Reject old or future-dated timestamps to limit replay attacks.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > toleranceSeconds) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  // During a secret rotation the header carries two signatures: "v1,<a> v1,<b>".
  return signatureHeader.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

With Express, take the raw body for the webhook route:

app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyWebhook(req.body, req.headers, process.env.WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  // Deduplicate on req.headers["webhook-id"], queue the work, then answer fast.
  res.status(204).end();
});

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "math"
    "net/http"
    "strconv"
    "strings"
    "time"
)

// VerifyWebhook checks an Outbound Gateway delivery. body must be the exact bytes received.
func VerifyWebhook(body []byte, header http.Header, secret string, tolerance time.Duration) bool {
    id := header.Get("webhook-id")
    timestamp := header.Get("webhook-timestamp")
    signatures := header.Get("webhook-signature")
    if id == "" || timestamp == "" || signatures == "" {
        return false
    }
    unix, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || math.Abs(time.Since(time.Unix(unix, 0)).Seconds()) > tolerance.Seconds() {
        return false
    }
    key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
    if err != nil {
        return false
    }
    mac := hmac.New(sha256.New, key)
    mac.Write([]byte(id + "." + timestamp + "."))
    mac.Write(body)
    expected := mac.Sum(nil)

    // During a secret rotation the header carries two signatures: "v1,<a> v1,<b>".
    for _, entry := range strings.Fields(signatures) {
        version, signature, ok := strings.Cut(entry, ",")
        if !ok || version != "v1" {
            continue
        }
        received, err := base64.StdEncoding.DecodeString(signature)
        if err == nil && hmac.Equal(received, expected) {
            return true
        }
    }
    return false
}

Any Standard Webhooks library verifies these headers too. To check a captured request by hand, paste it into the Standard Webhooks signature verifier.

Deduplicate on webhook-id

Delivery is at least once. The same message can arrive more than once, for example when your endpoint processed a request but timed out before answering, when a delivery is retried manually, or when failed deliveries are recovered. Every copy has the same webhook-id. Store the IDs you have processed (with a unique constraint or a short-lived cache) and acknowledge repeats with a 2xx without processing them again.

Respond quickly. Do the slow work after acknowledging, from a queue. A response that takes longer than the endpoint timeout (15 seconds by default, configurable up to 60) counts as a failed attempt and is retried.

Retries and backoff

ResponseResult
Any 2xxDelivered (sent).
429 or 503 with Retry-AfterRetried no sooner than Retry-After (capped at one hour).
410 GoneFails for good and disables the endpoint.
3xx, other 4xx/5xx, timeout, connection errorRetried on the schedule below.

After a failed attempt, the next one is scheduled after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h, for up to eight attempts within 48 hours of publication. A delivery that runs out of attempts is marked failed, and it can be recovered for up to 35 days. Each endpoint is retried independently: one endpoint's failures never delay another endpoint's deliveries.

Endpoint states

StateDeliveriesHow it gets there
activeSentNew endpoints. Any successful attempt returns a failing endpoint to active.
failingSent and retried5 failed attempts in a row, or one delivery that ran out of retries.
pausedSkipped and recordedYou paused the endpoint.
disabledSkipped and recordedThe endpoint answered 410 Gone, or auto_disable is on and 100 attempts in a row failed over at least 12 hours.

Resuming a paused or disabled endpoint (POST /v1/outbound/endpoints/{id}/resume) reactivates it and resets its failure count. Messages skipped in the meantime are not sent automatically; replay them once the endpoint is ready.

Endpoint failures belong to your customers' systems, so they never open incidents or send email to your account. Track them from endpoint health.

Rotate a signing secret

POST /v1/outbound/endpoints/{id}/secret/rotate returns a new secret. For 24 hours, deliveries are signed with both the new and the previous secret, so webhook-signature carries two signatures and receivers that accept any match keep working while they switch to the new secret. You can't rotate again until the overlap ends. POST /v1/outbound/endpoints/{id}/secret/reveal returns the current secret.

curl --user "$RELAY_KEY:$RELAY_SECRET" -X POST \
  https://my.webhookrelay.com/v1/outbound/endpoints/6f0c2c0e-5a3b-4f43-9d0a-4c4f7d7a2b11/secret/rotate
# {"secret":"whsec_…"}

In the SDKs, use relay.outbound.endpoints.rotateSecret(id) or api.RotateOutboundEndpointSecret(id).

Frequently asked questions

How do I verify an Outbound Gateway webhook signature?

Compute HMAC-SHA256 over '{webhook-id}.{webhook-timestamp}.{raw body}' using the base64-decoded part of the endpoint secret after 'whsec_' as the key. Base64-encode the result and compare it in constant time with each 'v1,<signature>' entry in the webhook-signature header. Any Standard Webhooks library does the same.

Can the same webhook be delivered more than once?

Yes. Delivery is at least once: a retry after a timeout, a manual retry or a recovery replay can deliver a message again. Every copy carries the same webhook-id header, so receivers should store processed webhook-id values and skip repeats.

How long does Webhook Relay retry a failed outbound webhook?

A delivery is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, for up to eight attempts within 48 hours of publication. Any 2xx response is a success. A 429 or 503 response's Retry-After header (up to one hour) delays the next attempt. 410 Gone stops retrying and disables the endpoint.

What happens to signatures when a signing secret is rotated?

For 24 hours after a rotation, every delivery is signed with both the new and the previous secret, so the webhook-signature header carries two signatures. Receivers that accept any matching signature keep working while they switch to the new secret.

Did this page help you?