Skip to content

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.

POST
/webhooks

Create a new webhook endpoint. Subscribe to specific events.

json
{
  "url": "https://yourdomain.com/webhooks/poststack",
  "events": [
    "email.delivered",
    "email.bounced",
    "email.complained",
    "email.opened",
    "email.clicked"
  ]
}
GET
/webhooks

List all webhook endpoints for your account.

json
{
  "webhooks": [
    {
      "id": 1,
      "url": "https://yourdomain.com/webhooks/poststack",
      "events": ["email.delivered", "email.bounced"],
      "active": true,
      "created_at": "2026-03-23T10:00:00.000Z"
    }
  ]
}
GET
/webhooks/:id

Retrieve a single webhook endpoint with its configuration.

json
{
  "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"
  }
}
PATCH
/webhooks/:id

Update a webhook's URL, events, or enabled status.

json
{
  "url": "https://yourdomain.com/webhooks/v2",
  "events": ["email.delivered", "email.bounced", "email.opened"],
  "enabled": true
}
DELETE
/webhooks/:id

Delete a webhook endpoint. No further events will be delivered.

json
{
  "success": true
}
POST
/webhooks/:id/test

Send a test event to your webhook endpoint to verify it is working correctly.

json
{
  "success": true
}
GET
/webhooks/:id/deliveries

List recent webhook delivery attempts with status, response codes, and timestamps.

json
{
  "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
  }
}
POST
/webhooks/:id/deliveries/:did/replay

Replay a failed webhook delivery. Sends the original event payload to your endpoint again.

json
{
  "success": true
}
POST
/webhooks/:id/deliveries/batch-replay

Replay 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.

json
{
  "withinMinutes": 120,
  "eventType": "email.bounced",
  "limit": 500
}
POST
/webhooks/:id/rotate-secret

Rotate 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.

json
{
  "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

EventDescription
email.sentEmail has been sent to the recipient mail server
email.deliveredEmail was successfully delivered
email.bouncedEmail hard bounced (permanent failure)
email.soft_bouncedEmail soft bounced (temporary failure)
email.openedRecipient opened the email (if tracking enabled)
email.clickedRecipient clicked a link (if tracking enabled)
email.complainedRecipient marked the email as spam
email.unsubscribedRecipient clicked the unsubscribe link
email.failedEmail delivery failed
email.delivery_delayedEmail delivery was delayed
email.scheduledEmail was scheduled for future delivery
email.suppressedEmail was suppressed (recipient on suppression list)
email.inboundInbound email received on your domain

Contact Events

EventDescription
contact.createdA new contact was created
contact.updatedA contact was updated
contact.deletedA contact was deleted
contact.unsubscribedA contact unsubscribed

Domain Events

EventDescription
domain.createdA new domain was added
domain.updatedDomain settings were updated
domain.deletedA domain was removed
domain.verifiedDomain DNS verification succeeded
domain.failedDomain DNS verification failed
domain.dns_driftA verified domain lost a required DNS record (SPF, or MX on inbound domains)
domain.dkim_rotation_startedA DKIM key rotation began — the payload carries the new TXT record to publish
domain.dkim_rotation_activatedThe new DKIM key is live and signing; the old selector stays published until retire_after
domain.dkim_rotation_completedThe old DKIM selector was retired and its DNS record removed

Webhook Payload

Each webhook delivery includes the event type, timestamp, and relevant data:

json
{
  "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:

json
{
  "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.

typescript
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:

typescript
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' });