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 behttp(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:
| Header | Value |
|---|---|
X-Scheduleit-Signature | HMAC-SHA256 of the signed message, lowercase hex (64 chars, no prefix) |
X-Scheduleit-Timestamp | Unix time in seconds when the request was signed |
X-Scheduleit-Event-Id | The event's id |
X-Scheduleit-Idempotency-Key | Your idempotencyKey, or the event id — stable across retries, so use it to dedupe |
Content-Type | application/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
- Scheduling — pair a webhook with one-shot, interval, or cron.
- Email channel — the other delivery target.