Docs · Webhooks

Webhooks

Register an HTTPS endpoint and Owlpost POSTs a signed JSON event to it whenever something happens to your mail: sent, delivered, bounced, received. Delivery is at least once, retried with backoff, and replayable.

All routes on this page need the webhooks:manage scope.

Endpoints

POST/v1/webhooksBuilt

Body: endpoint, an https:// URL, and events, the event types to receive (all of them when absent or empty). An unknown event type is 422.

curl
curl https://api.owlpost.to/v1/webhooks \
  -H "Authorization: Bearer $OWLPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "https://app.example.com/hooks/owlpost", "events": ["email.delivered", "email.bounced", "message.received"]}'
response · 201
{
  "object": "webhook",
  "id": "01J9ZQ…",
  "endpoint": "https://app.example.com/hooks/owlpost",
  "events": ["email.delivered", "email.bounced", "message.received"],
  "signing_secret": "whsec_…"
}

The signing_secret is in this answer and nowhere else, ever. Store it now; if it is lost, delete the endpoint and register it again.

GET/v1/webhooksBuilt

{"object": "list", "data": [{"object", "id", "endpoint", "events", "created_at"}, …]}, without secrets.

DELETE/v1/webhooks/{id}Built

{"object": "webhook", "id": "…", "deleted": true}. Deliveries still queued for it are dropped.

Changing an endpoint's events in place and rotating its secret are Planned (#10). Inbound routing rules are endpoints too, each with its own secret.

Events

TypeWhen
email.sentAmazon SES accepted the message
email.failedOwlpost gave up on it (refused, every recipient suppressed, or out of attempts)
email.deliveredThe recipient's server accepted it
email.delivery_delayedDelivery is taking longer than usual
email.bouncedPermanent bounce; the address is now suppressed
email.soft_bouncedTemporary bounce; not suppressed
email.complainedMarked as spam; the address is now suppressed
email.rejectedSES rejected the message
email.opened, email.clickedSES reported an open or a click
email.unsubscribedA new unsubscribe or suppression was added (details)
message.receivedMail arrived and passed screening, or was released (payload)
message.heldMail arrived and screening held it (details)

No events are sent for mail sent with a test key. Test events and a test send are Planned (#11).

The payload

Every delivery is one POST with Content-Type: application/json and this envelope:

body
{
  "id": "01J9ZQ5X7K3M2N4P6R8S0T1V2W",
  "type": "email.bounced",
  "subject": "acct_01j9…",
  "created_at": "2026-10-03T09:00:00Z",
  "data": {
    "email_id": "em_01j9…",
    "recipients": ["old@example.org"],
    "occurred_at": "2026-10-03T08:59:58.000Z",
    "detail": {
      "bounce_type": "Permanent",
      "bounce_sub_type": "General",
      "diagnostic": "smtp; 550 5.1.1 user unknown",
      "complaint_type": null,
      "smtp_response": null,
      "reject_reason": null,
      "link": null
    }
  }
}

id is the event id: the same on every retry of this event, so dedupe on it. subject is your account id (or <account>/inbound/<rule> for a routing rule). data depends on the type:

Typesdata
email.sent, email.failed{email_id, from, subject, error}; error is null on sent
email.delivered, delivery_delayed, bounced, soft_bounced, complained, rejected, opened, clicked{email_id, recipients, occurred_at, detail}, as above. One event per SES notification; recipients lists everyone it names.
email.unsubscribed{email_id: null, to, topic, scope, source, occurred_at}
message.received, message.heldThe received message (shape)

Each delivery also carries three headers:

HeaderValue
Cratefield-Signaturet=<unix seconds>,v1=<hex>
Cratefield-Event-IdThe envelope's id
Cratefield-Event-TypeThe envelope's type

The Cratefield- prefix comes from the open-source delivery engine Owlpost runs on.

Verifying the signature

The signature is HMAC-SHA256, keyed with your endpoint's signing secret, over the string {t}.{raw body}: the timestamp from the header, a dot, and the request body exactly as received. It is written as lowercase hex after v1=.

  1. Read the raw body before any JSON parsing. Re-serialised JSON will not match.
  2. Split the header on ,; take t and every v1.
  3. Compute HMAC-SHA256(secret, t + "." + body). The key is the whole secret string as text, whsec_ prefix included.
  4. Compare in constant time against each v1; accept on any match.
  5. Reject a t too far from your clock (five minutes is a sensible window). Every attempt is signed afresh, so retries pass this check.

Because the timestamp is inside the signed string, a captured delivery cannot be replayed with a new timestamp.

Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.OWLPOST_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;

export function verifyOwlpostSignature(rawBody, header, secret, now = Date.now() / 1000) {
  if (!header) return false;
  let t = null;
  const v1 = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') t = value;
    if (key === 'v1') v1.push(value);
  }
  if (!t || !/^\d+$/.test(t) || v1.length === 0) return false;
  if (Math.abs(now - Number(t)) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.`)
    .update(rawBody) // a Buffer: the bytes as received
    .digest();
  return v1.some((hex) => {
    const given = Buffer.from(hex, 'hex');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

const app = express();

// express.raw keeps the body as a Buffer, untouched.
app.post('/hooks/owlpost', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyOwlpostSignature(req.body, req.get('Cratefield-Signature'), SECRET)) {
    return res.status(400).send('bad signature');
  }
  const event = JSON.parse(req.body.toString('utf8'));
  // Delivery is at least once: skip event.id if you have already handled it.
  console.log(event.type, event.data);
  res.sendStatus(200); // any 2xx; answer fast and do the work afterwards
});

app.listen(3000);

Retries

Owlpost sends each event to each matching endpoint independently and files the outcome:

Your endpoint answersOwlpost
Any 2xxDone
410 GoneStops at once and dead-letters the event (rejected). Answer 410 only when you mean "never send here again".
Anything else, a timeout, or no answerRetries after 30 s, then 1, 2, 4, 8, 16 and 32 minutes. After 8 failed attempts, about an hour of trying, the event is dead-lettered (attempts_exhausted).

Deliveries to private, loopback or cloud-metadata addresses are refused outright and dead-lettered. Events are queued in the same database transaction as the change that caused them, so an event exists exactly when its cause does; they go out on the next delivery pass, which runs every minute.

Dead letters and replay

GET/v1/webhooks/dead-lettersBuilt

Up to 100 events that gave up, for your account's endpoints.

response · 200
{
  "object": "list",
  "data": [{
    "id": "01J9ZR…",
    "webhook": "01J9ZQ…",
    "event_id": "01J9ZQ5X7K3M2N4P6R8S0T1V2W",
    "type": "email.bounced",
    "attempts": 8,
    "reason": "attempts_exhausted",
    "last_error": "endpoint answered 502 Bad Gateway",
    "status_code": 502
  }]
}

POST/v1/webhooks/dead-letters/{id}/replayBuilt

Queues the event again with a fresh set of attempts and removes the dead letter: {"id": "…", "replayed": true}. The event keeps its id, so a receiver that dedupes is safe. 404 when the dead letter is gone, or its endpoint has been deleted.