Webhooks
When Esy publishes to your site — through an outlet or a publication — it calls you, and signs the call. The secret never travels on the wire; you recompute the signature and compare.
The headers
Esy follows the Standard Webhooks convention. Every delivery carries three headers.
webhook-id: msg_5f0c… # unique per delivery — your idempotency key
webhook-timestamp: 1757721600 # unix seconds, and part of what is signed
webhook-signature: v1,K3m…= v1,9Qa…= # one or more signatures, space-separatedWhat is signed
The HMAC-SHA256 is computed over the id, the timestamp, and the raw body, joined with dots, using the secret you were shown when you created the outlet or publication:
{webhook-id}.{webhook-timestamp}.{raw request body}Verifying in Node
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
/**
* Verify an Esy webhook. `rawBody` must be the exact bytes received —
* parse JSON only after this returns true.
*/
export function verifyEsyWebhook(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
// Reject stale deliveries so a captured request cannot be replayed later.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${id}.${timestamp}.${rawBody}`)
.digest('base64');
// During a rotation Esy signs with both secrets; any match is valid.
return signatures.split(' ').some((sig) => {
const [version, value] = sig.split(',');
if (version !== 'v1' || !value) return false;
const a = Buffer.from(value);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}// app/api/esy/route.ts
import { verifyEsyWebhook } from '@/lib/verify-esy-webhook';
export async function POST(request: Request) {
const raw = await request.text(); // BEFORE parsing — the signature covers bytes
const ok = verifyEsyWebhook(raw, Object.fromEntries(request.headers), process.env.ESY_WEBHOOK_SECRET!);
if (!ok) return new Response('invalid signature', { status: 401 });
const event = JSON.parse(raw);
// …handle it, keyed on the webhook-id header so a retry is a no-op
return Response.json({ received: true });
}Rules your receiver should follow
| Rule | Why |
|---|---|
| Compare with a constant-time function. | A plain === leaks the signature one byte at a time through timing. |
| Reject timestamps older than a few minutes. | The timestamp is signed, so this stops replay of a captured request. |
| Deduplicate on webhook-id. | Deliveries are retried. The same id twice is the same event. |
| Accept any matching signature in the list. | During rotation Esy signs with the old and new secret at once. |
| Reply quickly, work later. | Acknowledge, then do slow processing asynchronously. |
200 means you took all of it; 202 means you took part of it and Esy should push again to finish. Esy re-pushes automatically on a 202.Rotating and testing a secret
Rotate, deploy the new secret alongside the old one, confirm with verify, then drop the old one. Because deliveries are signed with both during the overlap, nothing fails in between.
Inbound webhooks
Esy does not currently accept webhooks from you. Everything flows outward — Esy calls your endpoints; you call the API. See the Connect a consumer site guide for a complete receiver.
- Three headers:
webhook-id,webhook-timestamp,webhook-signature. - HMAC-SHA256 over
{id}.{timestamp}.{raw body}, base64, prefixedv1,. - Verify raw bytes, compare in constant time, reject stale timestamps, dedupe on the id.