Scheduleit

Webhook channel

A webhook target delivers the event's payload as an HTTP POST to your URL, signed so you can verify it came from Scheduleit.

Target shape

{ "channel": "webhook", "url": "https://example.com/hook", "headers": { "X-My-Tag": "abc" } }
  • url — where to POST. Must be http(s).
  • headers (optional) — extra request headers, merged into the delivery. They're applied last, so they can override the built-in headers below — use with care.

The request method is always POST. The body is your event payload (the decoded payloadBase64) sent as raw bytes with Content-Type: application/octet-stream — Scheduleit doesn't re-encode or wrap it.

What we send

Every delivery carries these headers:

HeaderValue
X-Scheduleit-SignatureHMAC-SHA256 of the signed message, lowercase hex (64 chars, no prefix)
X-Scheduleit-TimestampUnix time in seconds when the request was signed
X-Scheduleit-Event-IdThe event's id
X-Scheduleit-Idempotency-KeyYour idempotencyKey, or the event id — stable across retries, so use it to dedupe
Content-Typeapplication/octet-stream

Use X-Scheduleit-Idempotency-Key to make your handler idempotent: a retried delivery repeats the same value, so you can safely ignore one you've already processed.

Verify the signature

The signature is HMAC-SHA256(secret, "<timestamp>." + rawBody), hex- encoded. The signed message is the X-Scheduleit-Timestamp value, a literal ., then the exact raw body bytes — no JSON canonicalization. Verify against the raw bytes before parsing them.

const crypto = require('node:crypto')

// secret : your project's `whsec_...` value, used VERBATIM as the key
//          (do NOT strip the whsec_ prefix or base64-decode it).
// rawBody: the exact bytes received (a Buffer) — never re-serialize
//          before verifying.
function verify(secret, rawBody, headers, toleranceSeconds = 300) {
  const timestamp = headers['x-scheduleit-timestamp']
  const signature = headers['x-scheduleit-signature']
  if (!timestamp || !signature) return false

  // Reject stale deliveries (default window: 5 minutes).
  const ts = Number.parseInt(timestamp, 10)
  if (!Number.isFinite(ts)) return false
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSeconds) return false

  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody)
  const signed = Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), body])
  const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex')

  return (
    expected.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'))
  )
}

// Express: capture raw bytes so the signature matches —
//   app.use(express.raw({ type: '*/*' }))
//   verify(process.env.WEBHOOK_SECRET, req.body, req.headers)

Two things people trip on: use the secret verbatim (the whsec_ prefix is part of the key), and verify the raw bytes — if your framework parses JSON first and you re-stringify, the signature won't match.

The signing secret

Each project has one signing secret, whsec_ followed by 32 hex characters. Find it — and rotate it — on the project's page in the operator console (or via the projects.rotateWebhookSecret API operation). After a rotation, new deliveries switch to the new secret within about 30 seconds; a delivery already in flight may still carry the old one, so accept both briefly when you rotate.

Delivery, retries, and dead-letter

A delivery succeeds on any 2xx. Anything else — a non-2xx status, a network error, or a response slower than the 15-second timeout — is a failure and gets retried.

Retries follow an increasing backoff with ±20% jitter:

5s → 30s → 2m → 10m → 1h → 6h

That's 7 attempts total (the first try plus six retries) over roughly seven hours. If all seven fail, the event moves to dead_letter. Inspect and replay dead-lettered events with GET /api/dead-letter and POST /api/dead-letter/:id/replay (replay resets the attempt count and fires immediately), or from the operator console. For the full per-attempt history of any event — status codes, timings, errors — read GET /api/events/:id/deliveries.

See also