Webhooks
Receive real-time notifications for email, contact, and domain events via HTTP webhooks. PostStack sends POST requests to your endpoint when events occur. Includes delivery tracking and replay for failed deliveries.
/webhooksCreate a new webhook endpoint. Subscribe to specific events.
{
"url": "https://yourdomain.com/webhooks/poststack",
"events": [
"email.delivered",
"email.bounced",
"email.complained",
"email.opened",
"email.clicked"
]
}/webhooksList all webhook endpoints for your account.
{
"webhooks": [
{
"id": 1,
"url": "https://yourdomain.com/webhooks/poststack",
"events": ["email.delivered", "email.bounced"],
"active": true,
"created_at": "2026-03-23T10:00:00.000Z"
}
]
}/webhooks/:idRetrieve a single webhook endpoint with its configuration.
{
"webhook": {
"id": 1,
"url": "https://yourdomain.com/webhooks/poststack",
"events": ["email.delivered", "email.bounced", "email.opened"],
"active": true,
"created_at": "2026-03-23T10:00:00.000Z"
}
}/webhooks/:idUpdate a webhook's URL, events, or enabled status.
{
"url": "https://yourdomain.com/webhooks/v2",
"events": ["email.delivered", "email.bounced", "email.opened"],
"enabled": true
}/webhooks/:idDelete a webhook endpoint. No further events will be delivered.
{
"success": true
}/webhooks/:id/testSend a test event to your webhook endpoint to verify it is working correctly.
{
"success": true
}/webhooks/:id/deliveriesList recent webhook delivery attempts with status, response codes, and timestamps.
{
"deliveries": [
{
"id": 1,
"webhook_id": 1,
"event": "email.delivered",
"status_code": 200,
"success": true,
"attempts": 1,
"created_at": "2026-03-23T10:00:02.000Z",
"next_attempt_at": null
},
{
"id": 2,
"webhook_id": 1,
"event": "email.bounced",
"status_code": 500,
"success": false,
"attempts": 3,
"created_at": "2026-03-23T10:05:00.000Z",
"next_attempt_at": "2026-03-23T11:05:00.000Z"
}
],
"pagination": {
"page": 1,
"perPage": 20,
"total": 156,
"totalPages": 8
}
}/webhooks/:id/deliveries/:did/replayReplay a failed webhook delivery. Sends the original event payload to your endpoint again.
{
"success": true
}/webhooks/:id/deliveries/batch-replayReplay EVERY failed delivery for this endpoint, oldest first. This is the one you want after fixing an endpoint that was down: replaying in order is what makes a downstream consumer's state come out right. Deliveries that already succeeded are never re-sent, so it cannot duplicate events you have processed. Capped at 1,000 per call — if the response's remaining is above zero, call again.
{
"withinMinutes": 120,
"eventType": "email.bounced",
"limit": 500
}/webhooks/:id/rotate-secretRotate the webhook's signing secret. Returns a new plaintext secret (shown once). The old secret stays valid for a grace window — 24 hours by default, up to 168 — during which every delivery is signed with both secrets, so you can ship the new one without dropping events. Pass graceHours: 0 to cut over immediately, which is the right call if the old secret leaked.
{
"graceHours": 24
}Delivery & retries: any 2xx response counts as delivered. Non-2xx responses are retried with exponential backoff (up to 8 attempts). Respond with 406 Not Acceptable to reject an event without triggering retries — useful for events you intentionally don't want (deduped or irrelevant). Rejected events don't count toward the consecutive-failure limit that auto-disables an endpoint.
Webhook Events
Subscribe to any combination of the following events:
Email Events
| Event | Description |
|---|---|
email.sent | Email has been sent to the recipient mail server |
email.delivered | Email was successfully delivered |
email.bounced | Email hard bounced (permanent failure) |
email.soft_bounced | Email soft bounced (temporary failure) |
email.opened | Recipient opened the email (if tracking enabled) |
email.clicked | Recipient clicked a link (if tracking enabled) |
email.complained | Recipient marked the email as spam |
email.unsubscribed | Recipient clicked the unsubscribe link |
email.failed | Email delivery failed |
email.delivery_delayed | Email delivery was delayed |
email.scheduled | Email was scheduled for future delivery |
email.suppressed | Email was suppressed (recipient on suppression list) |
email.inbound | Inbound email received on your domain |
Contact Events
| Event | Description |
|---|---|
contact.created | A new contact was created |
contact.updated | A contact was updated |
contact.deleted | A contact was deleted |
contact.unsubscribed | A contact unsubscribed |
Domain Events
| Event | Description |
|---|---|
domain.created | A new domain was added |
domain.updated | Domain settings were updated |
domain.deleted | A domain was removed |
domain.verified | Domain DNS verification succeeded |
domain.failed | Domain DNS verification failed |
domain.dns_drift | A verified domain lost a required DNS record (SPF, or MX on inbound domains) |
domain.dkim_rotation_started | A DKIM key rotation began — the payload carries the new TXT record to publish |
domain.dkim_rotation_activated | The new DKIM key is live and signing; the old selector stays published until retire_after |
domain.dkim_rotation_completed | The old DKIM selector was retired and its DNS record removed |
Webhook Payload
Each webhook delivery includes the event type, timestamp, and relevant data:
{
"type": "email.delivered",
"created_at": "2026-03-23T10:00:02.000Z",
"data": {
"email_id": "em_abc123def456ghi789",
"from": "you@yourdomain.com",
"to": "user@example.com",
"subject": "Welcome to PostStack"
}
}Bounce events (email.bounced and email.soft_bounced) add the receiving server's diagnostic, its RFC 3463 status code, and a stable bounce_category you can branch on — one of invalid_mailbox, mailbox_full, spam_block, message_too_large, auth_failure, rate_limited, dns_failure, connection_failed, content_rejected, policy, or unknown:
{
"type": "email.bounced",
"created_at": "2026-03-23T10:00:02.000Z",
"data": {
"email_id": "em_abc123def456ghi789",
"from": "you@yourdomain.com",
"to": "user@example.com",
"subject": "Welcome to PostStack",
"bounce_message": "550 5.1.1 <user@example.com>: Recipient address rejected: User unknown",
"bounce_code": "5.1.1",
"bounce_category": "invalid_mailbox"
}
}Signature Verification
Every webhook request includes an X-PostStack-Signature header: a comma-separated list of sha256=… elements. The signature is the HMAC-SHA256 of the raw request body, keyed by your signing secret.
Your check must split that header and pass if your secret matches any element. A steady-state delivery carries one element, but rotating a signing secret (POST /webhooks/:id/rotate-secret, above) keeps the old secret valid for a grace window — 24 hours by default, up to 168 — and every delivery inside that window is signed with both secrets, the new one first. That is what lets you rotate now and deploy the new secret at your own pace instead of coordinating a hard cutover.
So a verifier that compares the whole header value against a single HMAC — or that takes only the text after the first = — works perfectly until the first rotation and then rejects every delivery in the grace window. Those 401s are permanent failures on our side: each event burns its full retry budget, and after 20 consecutive failures we auto-disable the endpoint and stop sending altogether — we email the team owner and raise a dashboard alert at that point, but the events delivered in the meantime are already gone. Accepting the list is what keeps a rotation a non-event.
Two things break signature checks more often than anything else. First, you must hash the exact bytes we sent — re-serializing a parsed body with JSON.stringify changes them and the HMAC will not match. Second, compare in constant time, and remember timingSafeEqual throws on a length mismatch, so guard the length first or a malformed header becomes a 500 instead of a 401.
import crypto from 'crypto';
import express from 'express';
function verifyWebhookSignature(
rawBody: Buffer,
signatureHeader: string | undefined,
secret: string,
): boolean {
if (typeof signatureHeader !== 'string') return false;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const expectedBytes = Buffer.from(expected, 'utf8');
// One header carries several signatures while a rotation grace window is
// open, so match ANY element — never the header as a whole. Unknown schemes
// are ignored rather than rejected, so adding a new algorithm alongside
// sha256 later won't break this check either.
return signatureHeader.split(',').some((element) => {
const [scheme, hex] = element.trim().split('=');
if (scheme !== 'sha256' || !hex) return false;
const provided = Buffer.from(hex, 'utf8');
// timingSafeEqual throws unless both buffers are the same length.
if (provided.length !== expectedBytes.length) return false;
return crypto.timingSafeEqual(provided, expectedBytes);
});
}
// Give this route the raw bytes, not a parsed object — express.json()
// would discard them and the signature could never match.
app.post(
'/webhooks/poststack',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-poststack-signature'];
if (!verifyWebhookSignature(req.body, signature, 'whsec_abc123...')) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString('utf8'));
switch (event.type) {
case 'email.delivered':
// Handle delivery
break;
case 'email.bounced':
// Handle bounce
break;
}
res.status(200).json({ received: true });
},
);The official SDKs implement all of this for you — multi-signature parsing, the constant-time compare and the length guard:
import { PostStack } from '@poststack.dev/sdk';
const ok = await PostStack.Webhooks.verify(
rawBody, // the exact string or bytes we POSTed
req.headers['x-poststack-signature'],
'whsec_abc123...',
);
if (!ok) return res.status(401).json({ error: 'Invalid signature' });