Webhooks
Subscribe to delivery events with HMAC-signed payloads.
Webhooks push SES delivery events to your backend in real time so
you don't have to poll GET /emails. A subscription is a
{ url, events[], isActive } triple; once active, every event of
the subscribed types fires a POST to your URL with an HMAC-signed
JSON body.
Subscribe
Dashboard: Webhooks → Add webhook. Pick an HTTPS URL on your
own backend, check which event types you care about
(delivered, bounced, complained, opened, clicked,
sent, rejected, rendering_failed, delivery_delayed),
submit. You're shown the signing secret once — store it in
your env.
Payload
{
"id": "wd_2bV4dXyZ8AfQpkw7Lp",
"type": "email.event",
"createdAt": "2026-05-22T09:14:30.121Z",
"data": {
"emailId": "em_abc...",
"event": {
"eventId": "evt_xyz..."
}
}
}Headers on the request:
| Header | Purpose |
|---|---|
Content-Type | application/json |
User-Agent | Zerokit-Webhook/1.0 |
X-Zerokit-Signature | HMAC-SHA256 hex of ${timestamp}.${body}. See below. |
X-Zerokit-Timestamp | Unix seconds. Use it for anti-replay. |
X-Zerokit-Event-Type | Convenience: the event type (delivered, etc.). |
X-Zerokit-Delivery-Id | This delivery's ID. Use to dedupe on retries. |
Verify the signature
The signature is HMAC-SHA256 of ${timestamp}.${rawBody}, hex
encoded — Stripe-style. The dot is a literal delimiter; the
timestamp prevents a replay attacker re-sending an old payload.
import crypto from 'node:crypto';
function verify(req: { body: string; headers: Record<string, string> }, secret: string) {
const signature = req.headers['x-zerokit-signature'];
const timestamp = req.headers['x-zerokit-timestamp'];
if (!signature || !timestamp) return false;
// Anti-replay — reject anything older than 5 minutes.
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age > 300 || age < -30) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.body}`)
.digest('hex');
// Constant-time compare so we don't leak via timing.
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex'),
);
}import hmac
import hashlib
import time
def verify(body: str, headers: dict, secret: str) -> bool:
signature = headers.get("x-zerokit-signature")
timestamp = headers.get("x-zerokit-timestamp")
if not signature or not timestamp:
return False
age = int(time.time()) - int(timestamp)
if age > 300 or age < -30:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.{body}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)The body must be the raw request bytes — not a re-serialised JSON object. Most frameworks (Express, Hono, Flask) need an explicit "raw body" mode for the verify path. Re-serialising will reorder keys / change whitespace and your signature won't match.
Delivery semantics
- Zerokit retries on non-2xx for up to 24 hours with exponential
backoff (1m, 2m, 4m, 8m, …). After 24h the delivery is marked
failedand stops. - We dedupe with
X-Zerokit-Delivery-Id— if your endpoint is idempotent on that header, retries are safe. - 10s connect + read timeout per attempt. Endpoints that need longer to process should ack immediately and queue work async on their side.
Best practices
- Verify the signature on every request. No signature, no trust — even if the request came from an IP you recognize.
- Return 200 quickly. Long-running processing (database writes, downstream API calls) should be queued. We retry on timeout, so a slow endpoint creates duplicates.
- One webhook per environment. Separate subscriptions for
stagingandprodso a staging bug can't replay against prod data. - Rotate secrets on suspected leak. Delete the webhook and recreate — there's no in-place rotation.