Signatures

Every delivery is signed as Standard Webhooks describes. Check it with an official library and your whsec_ secret, or with HMAC-SHA256 in any language.

Every webhook delivery is signed as Standard Webhooks describes. Anyone can send a POST to your URL, so check the signature before you trust the body.

The headers

HeaderWhat it holds
webhook-idThe event's id, such as evt_5Rt8Wq2Lm7Xn3Kb9Vz1PdJ. Every attempt of the same event carries the same id, so store it and skip the deliveries you have handled.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signaturev1, and a base64 HMAC-SHA256. For 24 hours after you roll the secret it holds 2 signatures, separated by a space.

The body is the event as JSON, the same object GET /events/{event_id} returns. Event types lists what it can hold.

A delivery, its body cut short
POST /listings HTTP/1.1
Host: hooks.northlight.test
Content-Type: application/json
webhook-id: evt_5Rt8Wq2Lm7Xn3Kb9Vz1PdJ
webhook-timestamp: 1791469022
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"object":"event","id":"evt_5Rt8Wq2Lm7Xn3Kb9Vz1PdJ","type":"listing.live","created_at":"2026-10-08T14:17:01Z","livemode":false,"project":"prj_4QzX1m9Lr2Vb7Nc8Tk3HwP","data":{...}}

Your secret

Adding an endpoint returns its secret once: whsec_ and the base64 of 32 random bytes. Store it in your secret manager; these docs read it from SUBMITATOR_WEBHOOK_SECRET. Each endpoint has its own secret, and live and test endpoints are separate.

Check it with a library

The official Standard Webhooks libraries check each signature in the header, and reject a timestamp more than 5 minutes from your clock. Pass them the raw body exactly as it arrived, the headers and your whsec_ secret. They return the event, or raise an error when the delivery is not ours.

verify-webhook.mjs
import { Webhook } from "standardwebhooks";

// The whsec_ secret of your endpoint, shown once when you added it.
const secret = process.env.SUBMITATOR_WEBHOOK_SECRET;

// Returns the event when the delivery is ours. Throws a
// WebhookVerificationError when the signature does not match or the
// timestamp is more than 5 minutes from your clock.
export function verifyWebhook(rawBody, headers, whsec = secret) {
  return new Webhook(whsec).verify(rawBody, headers);
}

Answer 400 to a delivery that fails the check: it did not come from us. Receive webhooks puts the check into a complete endpoint.

Check it without a library

The check takes 5 steps in any language:

  1. Take your secret without whsec_ and decode it from base64. That is the key.
  2. Join webhook-id, webhook-timestamp and the raw body with dots: {webhook-id}.{webhook-timestamp}.{body}.
  3. Sign that text with HMAC-SHA256 and the key, and encode the result in base64.
  4. Compare it, in constant time, with each v1, signature in webhook-signature. One match is enough.
  5. Reject the delivery when webhook-timestamp is more than 5 minutes from your clock.
verify-webhook.mjs
import { createHmac, timingSafeEqual } from "node:crypto";

// The same check without a library. rawBody is the body exactly as received.
export function verifySignature(rawBody, headers, whsec = process.env.SUBMITATOR_WEBHOOK_SECRET) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const key = Buffer.from(whsec.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 5 * 60;
  const matches = String(headers["webhook-signature"] ?? "")
    .split(" ")
    .some((part) => {
      const [version, signature = ""] = part.split(",");
      const given = Buffer.from(signature, "base64");
      return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
    });
  return fresh && matches;
}

Mistakes that break the check

  • Parsing the body before the check. JSON parsed and written again has other bytes, and the signature no longer matches. Check the raw body first, then parse it.
  • A clock that drifts. A server clock more than 5 minutes off rejects every delivery. Keep it in sync with NTP.
  • One secret for every endpoint. Each endpoint has its own secret, and test endpoints have their own too. Check with the secret of the endpoint the delivery came to.

Roll the secret

POST /webhook_endpoints/{webhook_endpoint_id}/rotate_secret returns a new secret, shown in that response only. For the next 24 hours each delivery carries 2 signatures, one per secret, so it passes with either and you can deploy the new secret without missing an event.

curl -X POST https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/rotate_secret \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"
Response 200 (some fields left out)
{
  "object": "webhook_endpoint",
  "id": "we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT",
  "livemode": false,
  "url": "https://hooks.northlight.test/listings",
  "secret": "whsec_BFXycpAAK/Ay+ue0CxyVr5JQrZILyVqHTkjpX3GtfmY=",
  "previous_secret_expires_at": "2026-10-09T15:30:00Z"
}

previous_secret_expires_at says when the old secret stops signing. Roll the secret right away if it leaks.

Send yourself a test event

POST /webhook_endpoints/{webhook_endpoint_id}/test sends a ping event to the endpoint now, whatever its events filter. Use it to check your signature code before a real event arrives; the delivery shows up in the endpoint's deliveries.

curl -X POST https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/test \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"

Next steps