About webhooks

Understand callbacks, subscriptions, signatures, delivery behavior, and retry rules in OmniLab webhooks.

3 min read

Learn how OmniLab turns product events into outbound HTTPS requests, so you can build a receiver that verifies, deduplicates, and handles retries correctly. If you are preparing a receiver or reviewing a webhook design with OmniLab, start here.

The two objects that matter

ObjectWhat it doesTypical fields
CallbackDefines where OmniLab sends requests and how the outbound request is shapedHTTPS URL, authentication mode, signing secret, optional method/body/URL/header transformation
SubscriptionDefines which events reach a callbackEvent types, optional filters, status

A single callback can receive several subscriptions. Subscriptions can narrow delivery to one tenant, one group, or one interaction, depending on what your downstream system needs.

Delivery flow

2xx 5xx / timeout / 429 410 Gone OmniLab source event Subscription match Callback selected Signature and auth applied Your HTTPS receiver Delivery complete Retry Callback deactivated

What OmniLab sends by default

  • HTTPS requests only
  • POST by default
  • application/json by default
  • The raw source event as the request body unless a transformation is configured
  • Delivery headers on every request
  • A maximum raw payload size of 256 KB per delivery request

OmniLab can also transform the method, URL, content type, request body, and custom headers when a downstream system expects a different shape.

Headers to expect

HeaderWhat it meansHow to use it
webhook-idUnique delivery identifier, prefixed with msg_Treat it as an idempotency key in your receiver
webhook-timestampUnix timestamp used for signingCheck freshness before you accept the request
webhook-signatureOne or more v1,<base64-hmac> signatures over the raw bodyVerify the request before processing it
User-AgentOmniLab-Webhook/1.0Helpful for diagnostics and allow-listing

Authentication modes for outbound callbacks

OmniLab can send callbacks with one of these authentication strategies:

  • No additional auth header
  • Basic auth
  • Bearer auth, using Authorization or another agreed header name
  • OAuth 2.0 client credentials, where OmniLab first exchanges the callback client ID and secret for an access token

If an OAuth 2.0 protected receiver returns 401, OmniLab can retry once after refreshing the access token.

Retry behavior and failure handling

  • 2xx responses are treated as success.
  • 5xx, transient network failures, timeouts, and 429 responses are retryable.
  • If your receiver returns 429, OmniLab can respect the Retry-After header when it is present.
  • 4xx responses other than 429 are treated as permanent failures.
  • 410 Gone deactivates the callback so later events stop generating outbound requests until the callback is corrected.
  • The same event and callback combination is deduplicated on OmniLab's side, but your receiver should still be idempotent because retries can reuse the same delivery identifier.

Signing and key rotation

OmniLab computes the signature from this exact string:

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

The signature uses HMAC-SHA256 and is returned with a v1, prefix. During signing-secret rotation, OmniLab can send more than one v1,... signature, space-separated, in the same webhook-signature header — your verifier needs to accept any one of them, not just the first.

Two things a naive verifier gets wrong:

  • Comparing signatures as strings. Checking string equality (or array membership) leaks timing information an attacker can use to guess the correct signature one byte at a time. Use a constant-time comparison instead.
  • Skipping the timestamp. A signature alone doesn't stop someone from capturing a genuine request and replaying it later — check that webhook-timestamp is recent before you accept the request.
Node.js signature verification example
import crypto from "node:crypto";

const MAX_TIMESTAMP_SKEW_SECONDS = 300; // 5 minutes

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 can hold more than one "v1,<signature>" pair during 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)
    );
  });
}

Accept any signature that matches during the rollout window, then retire the old secret on your side.

On this page