About webhooks
How callbacks, subscriptions, signatures and retries work when the platform sends events to your endpoint.
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
| Object | What it does | What you set |
|---|---|---|
| Callback | Says where requests go and how they are shaped | HTTPS URL, authentication mode, signing secret, optional request transformation |
| Subscription | Says which events reach a callback | Event 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
POSTwithContent-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:
| Header | What it holds | How to use it |
|---|---|---|
webhook-id | msg_, then the event ID and the callback ID. It stays the same on every retry. | Store it and ignore repeats |
webhook-timestamp | Unix time in seconds when this attempt was signed. Each attempt gets a new one. | Reject old requests |
webhook-signature | One or two v1,<base64> signatures, separated by a space | Verify before you process anything |
User-Agent | OmniLab-Webhook/1.0 | Logs and allow-lists |
Delivery guarantees
| Situation | What the platform does |
|---|---|
Your endpoint answers 2xx | Records the delivery as successful. |
5xx, 429, a redirect (3xx), a network error, or no answer within 30 seconds | Retries, up to 5 times after the first attempt. Redirects are never followed. |
| Time between attempts | Waits 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 answer | Ignores it. Retries keep the schedule above. |
Any other 4xx | Stops. The failure is permanent and the event is not retried. |
410 Gone | Stops, and switches the callback to Failing. It receives nothing more. See Monitor delivery health. |
401 from an endpoint that uses OAuth2 | Fetches 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 render | Stops. The failure is permanent. |
| The last retry fails | Drops the event for that callback. It is not sent again. |
| Several events in quick succession | Sends them in no guaranteed order. Use each event's time to order them. |
| An event larger than 256 KB | Does not deliver it. |
| Connecting to your endpoint | Uses 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:
webhook-id + "." + webhook-timestamp + "." + raw_request_bodyThe 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-timestampis recent.
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.