About webhooks

How callbacks, subscriptions, signatures and retries work when the platform sends events to your endpoint.

3 min read

Each event, such as a prize won or a booking made, reaches you as one signed HTTPS request. Your receiver checks it, ignores repeats and answers fast.

Callbacks and subscriptions

ObjectWhat it doesWhat you set
CallbackSays where requests go and how they are shapedHTTPS URL, authentication mode, signing secret, optional request transformation
SubscriptionSays which events reach a callbackEvent types, optional filters, Active or Paused

A callback can have several subscriptions. When more than one of them matches an event, the callback still receives a single request.

What each request contains

  • POST with Content-Type: application/json, unless the callback's transformation changes them.
  • The event as a CloudEvents JSON document, unless a body template replaces it. The event reference shows a full request.
  • These headers:
HeaderWhat it holdsHow to use it
webhook-idmsg_, then the event ID and the callback ID. It stays the same on every retry.Store it and ignore repeats
webhook-timestampUnix time in seconds when this attempt was signed. Each attempt gets a new one.Reject old requests
webhook-signatureOne or two v1,<base64> signatures, separated by a spaceVerify before you process anything
User-AgentOmniLab-Webhook/1.0Logs and allow-lists

Delivery guarantees

SituationWhat the platform does
Your endpoint answers 2xxRecords the delivery as successful.
5xx, 429, a redirect (3xx), a network error, or no answer within 30 secondsRetries, up to 5 times after the first attempt. Redirects are never followed.
Time between attemptsWaits about 0.5, 1, 2, 4 and 8 seconds: 15.5 seconds in all, plus each attempt's own time.
A Retry-After header on your answerIgnores it. Retries keep the schedule above.
Any other 4xxStops. The failure is permanent and the event is not retried.
410 GoneStops, and switches the callback to Failing. It receives nothing more. See Monitor delivery health.
401 from an endpoint that uses OAuth2Fetches a new token and retries once, straight away. A second 401 is permanent.
Your OAuth2 token endpoint returns no token, or a template fails to renderStops. The failure is permanent.
The last retry failsDrops the event for that callback. It is not sent again.
Several events in quick successionSends them in no guaranteed order. Use each event's time to order them.
An event larger than 256 KBDoes not deliver it.
Connecting to your endpointUses HTTPS with TLS 1.2 or later, and only to hosts that resolve to public IP addresses.

Verify the signature

The signature is an HMAC-SHA256 of this exact string, keyed with your signing secret decoded from base64:

Signed payload format
webhook-id + "." + webhook-timestamp + "." + raw_request_body

The result is base64-encoded and prefixed with v1,. After you rotate the signing secret, every delivery carries two signatures, separated by a space: one made with the new secret and one with the previous secret. That lasts until the next rotation. Accept the request when any one of them matches.

Two things a naive verifier gets wrong:

  • Comparing signatures as strings. String equality, or array membership, leaks timing information an attacker can use to guess the signature byte by byte. Use a constant-time comparison.
  • Skipping the timestamp. A signature alone doesn't stop someone from replaying a captured request later. Check that webhook-timestamp is recent.
verify.js
import crypto from "node:crypto";

const MAX_TIMESTAMP_SKEW_SECONDS = 300; // 5 minutes

export function isValidOmniLabSignature({
  header,
  webhookId,
  webhookTimestamp,
  rawBody,
  signingSecret,
}) {
  // Reject a stale or forward-dated timestamp so a captured-and-replayed
  // callback can't be re-submitted later and still pass verification.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(webhookTimestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > MAX_TIMESTAMP_SKEW_SECONDS) {
    return false;
  }

  const signedPayload = `${webhookId}.${webhookTimestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", Buffer.from(signingSecret, "base64"))
    .update(signedPayload)
    .digest();

  // The header holds two "v1,<signature>" pairs after a secret rotation,
  // so check each one instead of comparing the whole header at once.
  return header.split(" ").some((part) => {
    const [version, signature] = part.split(",");
    if (version !== "v1" || !signature) return false;

    let candidate;
    try {
      candidate = Buffer.from(signature, "base64");
    } catch {
      return false;
    }

    // timingSafeEqual throws if the two buffers aren't the same length, so
    // rule that out before doing the constant-time comparison itself.
    return (
      candidate.length === expected.length &&
      crypto.timingSafeEqual(candidate, expected)
    );
  });
}

Receive your first event runs this verifier in a complete receiver.

Next steps

On this page