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 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"]}'{
"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
| Type | When |
|---|---|
email.sent | Amazon SES accepted the message |
email.failed | Owlpost gave up on it (refused, every recipient suppressed, or out of attempts) |
email.delivered | The recipient's server accepted it |
email.delivery_delayed | Delivery is taking longer than usual |
email.bounced | Permanent bounce; the address is now suppressed |
email.soft_bounced | Temporary bounce; not suppressed |
email.complained | Marked as spam; the address is now suppressed |
email.rejected | SES rejected the message |
email.opened, email.clicked | SES reported an open or a click |
email.unsubscribed | A new unsubscribe or suppression was added (details) |
message.received | Mail arrived and passed screening, or was released (payload) |
message.held | Mail 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:
{
"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:
| Types | data |
|---|---|
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.held | The received message (shape) |
Each delivery also carries three headers:
| Header | Value |
|---|---|
Cratefield-Signature | t=<unix seconds>,v1=<hex> |
Cratefield-Event-Id | The envelope's id |
Cratefield-Event-Type | The 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=.
- Read the raw body before any JSON parsing. Re-serialised JSON will not match.
- Split the header on
,; taketand everyv1. - Compute
HMAC-SHA256(secret, t + "." + body). The key is the whole secret string as text,whsec_prefix included. - Compare in constant time against each
v1; accept on any match. - Reject a
ttoo 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.
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 answers | Owlpost |
|---|---|
Any 2xx | Done |
410 Gone | Stops at once and dead-letters the event (rejected). Answer 410 only when you mean "never send here again". |
| Anything else, a timeout, or no answer | Retries 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.
{
"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.