Zerokit

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

POST <your webhook URL>
{
  "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:

HeaderPurpose
Content-Typeapplication/json
User-AgentZerokit-Webhook/1.0
X-Zerokit-SignatureHMAC-SHA256 hex of ${timestamp}.${body}. See below.
X-Zerokit-TimestampUnix seconds. Use it for anti-replay.
X-Zerokit-Event-TypeConvenience: the event type (delivered, etc.).
X-Zerokit-Delivery-IdThis 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.

TypeScript
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'),
  );
}
Python
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 failed and 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 staging and prod so 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.

On this page