Skip to main content

Security policy

Anyone who knows your endpoint URL could send it requests. To be sure an event genuinely comes from Tomorro, verify the signature on every request before acting on it.

Signing secret

Each webhook has its own secret, generated when you create it and shared only between Tomorro and you. Tomorro uses it to sign every event; you use it to verify them. Treat it like a password — never expose it client-side or commit it to source control.

Signature header

Every event is sent with a signature header. Tomorro signs the event by computing an HMAC-SHA256 over the timestamp and the raw request body, using your webhook secret:
sha256 = HMAC_SHA256(secret, timestamp + "." + rawBody)
The signature is delivered in the Leeway-Signature header, in the form t=<timestamp>,sha256=<hash>:
Leeway-Signature: t=1492774577000,sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t — the timestamp the event was signed at, in milliseconds since the Unix epoch.
  • sha256 — the computed HMAC-SHA256 hash, hex-encoded.
For backward compatibility the same value is also sent in a Leeway_Signature header (with an underscore). Prefer the canonical Leeway-Signature; some proxies strip headers that contain underscores.

Verifying the signature

To verify, recompute the HMAC over timestamp + "." + rawBody with your secret and compare it to the sha256 value from the header.
Compute the HMAC over the raw request body bytes, exactly as received — do not re-serialize the parsed JSON. Re-serializing can reorder keys or change whitespace and break the comparison.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * @param {string} header   - the `Leeway-Signature` header value
 * @param {string} secret   - the webhook's signing secret
 * @param {string} rawBody  - the raw request body, exactly as received
 */
function isValidSignature(header, secret, rawBody) {
  // Parse "t=<timestamp>,sha256=<hash>"
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('=')));
  const { t: timestamp, sha256: received } = parts;
  if (!timestamp || !received) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // Constant-time comparison to avoid timing attacks
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  if (a.length !== b.length || !timingSafeEqual(a, b)) return false;

  // Replay protection: reject events older than 5 minutes
  const ageMs = Math.abs(Date.now() - Number(timestamp));
  return ageMs < 5 * 60 * 1000;
}
Always use a constant-time comparison (e.g. timingSafeEqual) rather than === to avoid timing attacks.

Replay protection

The signed timestamp also lets you reject replayed events: compare t to the current time and discard anything outside a tolerance window you control (5 minutes is a reasonable default, as shown above). This prevents an attacker from re-sending a previously valid, intercepted request.

Delivery & retries

What counts as a successful delivery

When Tomorro sends an event, your endpoint must:
  • respond with a 2xx HTTP status code, and
  • do so within the 3-second timeout.
Any other status code, or a response that takes longer than the timeout, is treated as a failed delivery.
Acknowledge fast, process later. Return 2xx as soon as you’ve received the event, then do any heavy work asynchronously. This keeps you comfortably under the 3-second timeout.

Retry policy

If a delivery fails, Tomorro automatically retries it:
  • Failed events are re-queued with a 5-minute delay between attempts.
  • Tomorro retries up to 10 times for a given event.
  • If every attempt fails, Tomorro stops retrying and disables the webhook. A disabled webhook receives no further events until you re-enable it from the Webhooks settings.
Because retries are spaced 5 minutes apart, your endpoint may receive the same event more than once. Make your handler idempotent — deduplicate using the eventId field so reprocessing a delivery has no side effects.
Exact timeout and retry values may change over time as we tune delivery.