Skip to content

Webhooks

PostStack POSTs a signed JSON event to your URL when a message is delivered, bounces, is opened or clicked, when a contact changes, and when a domain's DNS status changes.

Quick start#

  1. Add a route that accepts POST

    It must be reachable from the internet over http or https and answer with any 2xx within 10 seconds. Acknowledge first and do slow work afterwards — a response that arrives after 10 seconds counts as a failure even if you processed the event.

  2. Register it and store the secret

    POST /webhooks with the URL and the events you want. The response contains signingSecret — the only time you will see it.

  3. Verify every request

    Check X-PostStack-Signature against the raw body before you trust the payload. See Signature verification below.

  4. Send yourself a test event

    POST /webhooks/:id/test queues a delivery of type test. Then GET /webhooks/:id/deliveries shows the status code and the first 1,000 characters of what your endpoint answered.

Every plan includes webhooks, up to 20 per team. Each webhook has its own URL, event list and signing secret.

Events#

A webhook receives only the events listed in its events array. There is no wildcard: to receive everything, list every event. These are all the values events accepts — anything else is rejected with a 400.

Email events

EventFires whenData fields
email.scheduledA send with a future scheduled_at was accepted and queued. Fires at request time, not at send time.email_id, from, to, subject, scheduled_at
email.sentPostStack handed the message to its outbound mail server. Also fires once when a broadcast finishes, with a different data shape (see Payloads).email_id, from, to, subject
email.deliveredThe recipient's mail server accepted the message. smtp_response is that server's 250 reply verbatim and remote_message_id the id it assigned (null when its reply carries none) — quote it to the recipient's provider or admin when a message is reported missing.email_id, from, to, subject, recipient, mx, smtp_response, remote_message_id
email.delivery_delayedHanding the message to the outbound mail server failed and PostStack will retry. A send gets 3 attempts; this fires after attempts 1 and 2.email_id, from, to, subject, error, attempt
email.failedThe third and final send attempt failed. The message will not be retried.email_id, from, to, subject, error
email.bouncedThe receiving server rejected the message permanently (hard bounce). Fires once per message.email_id, from, to, subject, bounce_message, bounce_code, bounce_category
email.soft_bouncedThe receiving server reported a temporary failure (soft bounce).email_id, from, to, subject, bounce_message, bounce_code, bounce_category
email.complainedA recipient reported the message as spam through a mailbox provider feedback loop. Every To recipient of the message is suppressed.email_id, from, to, subject, complaint_message
email.openedThe open-tracking pixel was fetched. Fires on every fetch, including repeat opens and automated fetches by mail scanners.email_id, ip_address, user_agent
email.clickedA tracked link was followed. Fires on every click, including automated link scanners.email_id, url, ip_address, user_agent
email.unsubscribedA recipient unsubscribed through an unsubscribe link or a one-click List-Unsubscribe request. The address is added to your suppression list.public_id, id, email
email.suppressedA send dropped some of its recipients because they are on your suppression list, and still went out to the rest.suppressed_recipients, from, subject
email.inboundA message arrived for an address on one of your inbound-enabled domains. Fires once per receiving address — except for a mailbox whose webhookEnabled is false.id, from, from_name, to, cc, subject, domain, mailbox_hash, spam_score, spam_verdict, stripped_reply, received_at

Contact events

EventFires whenData fields
contact.createdA contact was created through the API, the dashboard or a signup form. Contacts added by a CSV import do not fire it; a repeat signup-form submission for an existing contact does not either.public_id, id, email, first_name, last_name
contact.updatedA contact was updated through the API or the dashboard.public_id, id, email, first_name, last_name
contact.deletedA contact was deleted.public_id, id, email
contact.unsubscribedA contact was unsubscribed from all mail — by POST /contacts/:id/unsubscribe, or by the recipient through an unsubscribe link. Unsubscribing from a single topic does not fire it.public_id, id, email

Domain events

EventFires whenData fields
domain.createdA domain was added.id, name, region
domain.updatedA domain’s settings were changed with PATCH /domains/:id or in the dashboard.id, name
domain.deletedA domain was deleted.id
domain.verifiedA DNS check found every required record and the domain’s status changed to verified.id, name, verified_at
domain.failedA domain that was verified failed a DNS check and went back to pending, which stops sending from it. A pending domain whose check fails does not fire it.id, name, failed_at, failed_records
domain.dns_driftA verified domain’s SPF record — or its MX record, if inbound is enabled — no longer matches. The domain stays verified. At most once per record per 24 hours.id, name, purpose, record_name, expected, actual, detected_at
domain.dkim_rotation_startedA DKIM key rotation began. The payload carries the new TXT record to publish.id, name, selector, record
domain.dkim_rotation_activatedPostStack switched to signing with the new DKIM key. Keep the old record published until retire_after.id, name, selector, previous_selector, retire_after
domain.dkim_rotation_completedThe old DKIM selector was retired. If your DNS provider is connected, its record was also removed.id, name, retired_selector

Test mode sends fire events too

A send made with a test API key is never delivered, but it still fires email.sent and then email.delivered, each with "test_mode": true in data — so you can exercise your handler end to end without touching a real inbox.

Payloads#

Every delivery is an HTTP POST with a JSON body in the same envelope, and these request headers:

HeaderValue
Content-Typeapplication/json
X-PostStack-Signaturesha256=<hex>, or two comma-separated elements during a secret rotation — see Signature verification below.
User-AgentPostStack-Webhook/1.0

The envelope

typestring
The event name, e.g. email.delivered. A delivery triggered by POST /webhooks/:id/test has the type test.
created_atstring (ISO 8601)
When PostStack dispatched the event. Retries and replays resend the stored payload, so this value never changes between attempts.
dataobject
The event's fields. Its shape depends on the event — see below.

Deliveries can repeat — make your handler idempotent

A delivery that timed out on our side may have been processed on yours, and a replay resends an event you may already have handled. The envelope carries no event ID; because a retry or replay resends the same payload, type + created_at + the resource ID in data (email_id, id) is a stable key to deduplicate on. Events are not guaranteed to arrive in order, and replayed payloads may come back with their keys in a different order — parse the JSON, never compare it as text.

Email payloads

email.sent, email.delivered, email.delivery_delayed and email.failed share four fields. email_id is the em_… ID that POST /emails returned; from is the bare sender address; to is the message's To list (Cc and Bcc recipients are not included).

json
{
  "type": "email.delivered",
  "created_at": "2026-09-29T10:00:02.114Z",
  "data": {
    "email_id": "em_k3v9x2m7q1w8r4t6y0p5s2d8",
    "from": "billing@yourdomain.com",
    "to": ["ada@example.com"],
    "subject": "Your receipt"
  }
}

email.delivery_delayed adds error (why the hand-off failed) and attempt (1 or 2); email.failed adds error. Sends made with a test-mode key add "test_mode": true. email.scheduled has the same four fields plus scheduled_at, but its from is the value exactly as you sent it, display name included.

json
{
  "type": "email.delivery_delayed",
  "created_at": "2026-09-29T10:00:07.530Z",
  "data": {
    "email_id": "em_k3v9x2m7q1w8r4t6y0p5s2d8",
    "from": "billing@yourdomain.com",
    "to": ["ada@example.com"],
    "subject": "Your receipt",
    "error": "Connection timeout",
    "attempt": 1
  }
}

When a broadcast finishes sending, email.sent fires once more with a different data — branch on the presence of broadcast_id. The individual messages of the broadcast each fire their own message events as well.

json
{
  "type": "email.sent",
  "created_at": "2026-09-29T11:42:10.003Z",
  "data": {
    "broadcast_id": "bc_7f2k9q4m1x8w3r6t0y5p2s7d",
    "total_recipients": 4210
  }
}

Bounces

email.bounced and email.soft_bounced add the receiving server's diagnostic (bounce_message), its RFC 3463 status code (bounce_code, or null when the server sent none) and a bounce_category you can branch on. to is the message's whole To list, not only the address that bounced.

json
{
  "type": "email.bounced",
  "created_at": "2026-09-29T10:00:04.870Z",
  "data": {
    "email_id": "em_k3v9x2m7q1w8r4t6y0p5s2d8",
    "from": "billing@yourdomain.com",
    "to": ["ada@example.com"],
    "subject": "Your receipt",
    "bounce_message": "550 5.1.1 <ada@example.com>: Recipient address rejected: User unknown",
    "bounce_code": "5.1.1",
    "bounce_category": "invalid_mailbox"
  }
}

bounce_category is one of invalid_mailbox, mailbox_full, message_too_large, spam_block, policy, auth_failure, rate_limited, dns_failure, connection_failed, content_rejected or unknown.

Complaints

email.complained adds complaint_message, taken from the feedback-loop report.

Opens and clicks

These carry no from, to or subject — look the message up by email_id if you need them. ip_address and user_agent are null when the request did not carry them.

json
{
  "type": "email.clicked",
  "created_at": "2026-09-29T10:14:51.212Z",
  "data": {
    "email_id": "em_k3v9x2m7q1w8r4t6y0p5s2d8",
    "url": "https://yourdomain.com/invoices/1042",
    "ip_address": "203.0.113.24",
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15"
  }
}

Not every open or click is a person

Both events fire for every hit, including repeat opens and the automated fetches that mail scanners and privacy proxies make. PostStack excludes the machine hits it recognises from open rates in the dashboard, but the webhook does not flag them. Do not treat a single email.opened as proof a human read the message.

Unsubscribes and suppressions

email.unsubscribed carries the address that opted out. When that address belongs to one of your contacts, data is { "public_id", "id", "email" } — the same fields contact.unsubscribed sends at the same moment; otherwise it is { "email" } only. It has no email_id.

json
{
  "type": "email.unsubscribed",
  "created_at": "2026-09-29T12:03:44.019Z",
  "data": {
    "public_id": "con_9x2m4p8r1v6t0w5y2za7f3kq",
    "id": 81532,
    "email": "ada@example.com"
  }
}

email.suppressed fires from the send request itself, before any email_id is issued to you. It fires only when the send went ahead for the remaining recipients; a send where every recipient — or every To recipient — is suppressed is rejected with 422 and fires nothing.

json
{
  "type": "email.suppressed",
  "created_at": "2026-09-29T10:00:00.402Z",
  "data": {
    "suppressed_recipients": ["old-address@example.com"],
    "from": "Acme Billing <billing@yourdomain.com>",
    "subject": "Your receipt"
  }
}

Inbound email

id is the inbound message's ID in the Inbound Email API. mailbox_hash is the part after + in the receiving address (support+invoice-42@… → invoice-42) or null; spam_verdict is spam, clean or null; stripped_reply is the plain-text body with the quoted history removed, or null when the body is empty. The message body and attachments are not in the payload — fetch them from the Inbound Email API. Mail to a mailbox whose webhookEnabled is false is stored but does not fire this event.

json
{
  "type": "email.inbound",
  "created_at": "2026-09-29T09:12:30.551Z",
  "data": {
    "id": 5521,
    "from": "ada@example.com",
    "from_name": "Ada Lovelace",
    "to": "support+invoice-42@yourdomain.com",
    "cc": [],
    "subject": "Re: Invoice 42",
    "domain": "yourdomain.com",
    "mailbox_hash": "invoice-42",
    "spam_score": 1.2,
    "spam_verdict": "clean",
    "stripped_reply": "Paid this morning, thanks!",
    "received_at": "2026-09-29T09:12:30.548Z"
  }
}

Contact payloads

public_id is the contact's con_… ID — the one every /contacts/:id endpoint accepts. id is the internal numeric ID, kept for integrations built before public_id existed; no endpoint accepts it, so use public_id. contact.created and contact.updated also carry first_name and last_name (either may be null); contact.deleted and contact.unsubscribed carry only public_id, id and email.

json
{
  "type": "contact.created",
  "created_at": "2026-09-29T08:30:00.000Z",
  "data": {
    "public_id": "con_9x2m4p8r1v6t0w5y2za7f3kq",
    "id": 81532,
    "email": "ada@example.com",
    "first_name": "Ada",
    "last_name": null
  }
}

Domain payloads

id is the domain's numeric ID, which every /domains/:id endpoint accepts alongside the dom_… ID. domain.failed lists the purposes of the records that failed (spf, dkim, mx, …); domain.dns_drift names the one record that drifted, with what you should have published (expected) and what DNS returned (actual).

json
{
  "type": "domain.dns_drift",
  "created_at": "2026-09-29T06:00:12.940Z",
  "data": {
    "id": 42,
    "name": "yourdomain.com",
    "purpose": "spf",
    "record_name": "yourdomain.com",
    "expected": "v=spf1 include:poststack.dev ~all",
    "actual": ["v=spf1 include:_spf.google.com ~all"],
    "detected_at": "2026-09-29T06:00:12.939Z"
  }
}
json
{
  "type": "domain.dkim_rotation_started",
  "created_at": "2026-09-29T09:00:00.310Z",
  "data": {
    "id": 42,
    "name": "yourdomain.com",
    "selector": "ps-20260929-a1b2",
    "record": {
      "type": "TXT",
      "name": "ps-20260929-a1b2._domainkey.yourdomain.com",
      "value": "v=DKIM1; k=rsa; h=sha256; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…"
    }
  }
}

domain.dkim_rotation_activated carries selector (now signing), previous_selector and retire_after (ISO 8601); domain.dkim_rotation_completed carries retired_selector.

Test deliveries

json
{
  "type": "test",
  "created_at": "2026-09-29T10:00:00.000Z",
  "data": {
    "message": "This is a test webhook from PostStack"
  }
}

Signature verification#

Every delivery carries an X-PostStack-Signature header so you can prove it came from PostStack and was not altered. Reject any request whose signature does not match — your endpoint URL is not a secret.

How the signature is built

  1. Take the raw request body

    The exact bytes of the JSON body we sent. Nothing else is signed: no timestamp, no URL, no headers.

  2. HMAC it with your signing secret

    HMAC-SHA256, keyed with the whole signing secret as UTF-8 — the whsec_ prefix included — and hex-encoded in lowercase (64 characters).

  3. Prefix and join

    Each signature is written as sha256=<hex>. When there is more than one, they are joined with a comma and no spaces, current secret first:

    text
    X-PostStack-Signature: sha256=5d41402abc4b2a76b9719d911017c592ae2f1b6c2e1f3a1c8e0f7d9b4a6c3e21,sha256=9b74c9897bac770ffc029102a200c5de3c1f0e8a2b7d6c5f4e3d2c1b0a9f8e7d

So your check must split the header on commas and pass if your secret matches any sha256 element. Outside a rotation the header holds one element; while a rotation's grace window is open it holds two, and every delivery in that window is signed with both secrets. That window is 24 hours by default (anything from 0 to 168 hours, set per rotation), and it is what lets you rotate now and deploy the new secret at your own pace. Skip elements whose scheme is not sha256 rather than failing on them.

A single-signature check breaks on your first rotation

A verifier that compares the whole header against one HMAC — or keeps only the text after the first = — works until the first rotation, then rejects every delivery in the grace window. PostStack treats those 401s like any other failure: each event is tried 8 times over about 27 hours and then dropped, and after 20 events in a row fail that way the endpoint is disabled and stops receiving anything.

Verify with the SDKs

The official SDKs do all of this for you — the multi-signature parsing, the constant-time comparison and the length guard — and return false rather than throwing on a missing or malformed header. The TypeScript SDK takes the body as a string; Python accepts str or bytes; Go takes []byte.

import { PostStack } from '@poststack.dev/sdk';

// rawBody: the request body as a string, exactly as received.
const ok = await PostStack.Webhooks.verify(
  rawBody,
  request.headers.get('X-PostStack-Signature') ?? '',
  process.env.POSTSTACK_WEBHOOK_SECRET!,
);
if (!ok) return new Response('Invalid signature', { status: 401 });

Verify by hand

Without an SDK, the two mistakes that break signature checks most often are hashing a re-serialized body — JSON.stringify(JSON.parse(body)) is not the same bytes, so the HMAC will not match — and comparing with === instead of a constant-time compare. In Node, crypto.timingSafeEqual throws when the lengths differ, so check the length first or a malformed header becomes a 500 instead of a 401.

Express
import crypto from 'node:crypto';
import express from 'express';

const app = express();

function verifyWebhookSignature(
  rawBody: Buffer,
  signatureHeader: string | undefined,
  secret: string,
): boolean {
  if (typeof signatureHeader !== 'string') return false;

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
    'utf8',
  );

  // During a rotation grace window the header carries two signatures, so
  // match ANY element — never the header as a whole. Unknown schemes are
  // skipped, not rejected.
  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 !== expected.length) return false;
    return crypto.timingSafeEqual(provided, expected);
  });
}

// 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 ok = verifyWebhookSignature(
    req.body,
    req.get('X-PostStack-Signature'),
    process.env.POSTSTACK_WEBHOOK_SECRET!,
  );
  if (!ok) return res.status(401).json({ error: 'Invalid signature' });

  const event = JSON.parse(req.body.toString('utf8'));
  switch (event.type) {
    case 'email.delivered':
      // …
      break;
    case 'email.bounced':
      // …
      break;
  }

  res.sendStatus(200);
});

Delivery and retries#

Each event is sent once to every active webhook subscribed to it. What happens next depends on your endpoint's answer:

Your endpointWhat PostStack does
Any 2xxDelivered. The webhook’s consecutive-failure count goes back to 0.
406 Not AcceptableRejected on purpose: recorded, never retried, and not counted as a failure. Use it for events you do not want.
Any other status, including 3xx (redirects are not followed), a timeout after 10 seconds, or no connectionRetried, up to 8 attempts in all.
The hostname now resolves to a private or reserved addressBlocked and not retried.

Retries wait 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 8 hours and 16 hours, so an event keeps being retried for about 27 hours before PostStack gives up on it. The delivery log shows each delivery's attempts and nextRetryAt.

A dead endpoint is disabled after 20 events

When 20 events in a row have each used up all 8 attempts, PostStack disables the webhook: active becomes false and no further events are sent to it. The team owner gets an email, the team gets a dashboard notification, and any notification channel subscribed to webhook.disabled is told. Re-enable it with PATCH /webhooks/:id and { "enabled": true }. Events that fired while it was disabled were never queued for it, so they cannot be replayed.

Endpoints#

All endpoints live under https://api.poststack.dev, authenticate with Authorization: Bearer <key>, and need a full-access API key (or an OAuth token with the webhooks:manage scope). Errors come back as { "error": "…" }; a body or parameter that fails validation is a 400 naming the field.

Create a webhook#

Registers an endpoint and subscribes it to events. The response is the only time the signing secret is shown — store it before you do anything else.

POSThttps://api.poststack.dev/webhooks
Request
const { webhook, signingSecret } = await poststack.webhooks.create({
  url: 'https://yourdomain.com/webhooks/poststack',
  events: ['email.delivered', 'email.bounced', 'email.complained'],
});

// Shown exactly once. Put it in your secret store now.
console.log(webhook.id, signingSecret);
HTTP/1.1 201 Created

{
  "webhook": {
    "id": 12,
    "teamId": 3051,
    "url": "https://yourdomain.com/webhooks/poststack",
    "events": ["email.delivered", "email.bounced", "email.complained"],
    "active": true,
    "consecutiveFailures": 0,
    "disabledAt": null,
    "disabledReason": null,
    "secretRotatedAt": null,
    "previousSecretExpiresAt": null,
    "createdAt": "2026-09-29T10:00:00.000Z",
    "updatedAt": "2026-09-29T10:00:00.000Z"
  },
  "signingSecret": "whsec_3f9c1a7e5b2d8f4a6c0e9b1d7f3a5c8e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a"
}

Body parameters

urlstringrequired
An http or https URL. Its hostname must resolve, and every address it resolves to must be public — localhost, private and reserved ranges are rejected. Deliveries do not follow redirects, so use the final URL.
eventsstring[]required
At least one event name from the Events section.

Response fields

webhook.idinteger
The webhook’s ID, used in every other path.
webhook.teamIdinteger
The team that owns it.
webhook.urlstring
Where deliveries are POSTed.
webhook.eventsstring[]
The subscribed event names.
webhook.activeboolean
false when you disabled it or PostStack auto-disabled it. Inactive webhooks receive nothing: new events are not queued, and deliveries already queued or retrying are skipped when their turn comes (they stay failed, so a batch replay after re-enabling resends them).
webhook.consecutiveFailuresinteger
Events in a row that exhausted every retry. Reset to 0 by any successful delivery.
webhook.disabledAtstring (ISO 8601) | null
When PostStack auto-disabled the webhook, or null.
webhook.disabledReasonstring | null
Why it was auto-disabled, e.g. Auto-disabled after 20 consecutive failures; null otherwise.
webhook.secretRotatedAtstring (ISO 8601) | null
When the signing secret was last rotated; null if never.
webhook.previousSecretExpiresAtstring (ISO 8601) | null
While this is in the future, a rotation grace window is open and deliveries are signed with both secrets.
webhook.createdAtstring (ISO 8601)
When it was created.
webhook.updatedAtstring (ISO 8601)
When it last changed.
signingSecretstring
The plaintext secret, whsec_ followed by 64 hex characters. Never returned again; rotate to get a new one.

Status codes

StatusMeaning
201Created. The webhook is active immediately.
400Validation failed, or the URL was refused: URL must use http or https, Could not resolve webhook URL hostname, Webhook URL resolves to a private IP address, Webhook URL cannot point to internal services.
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended. Also Webhook limit reached (20 per team).
429More than 10 calls a minute to this endpoint with the same credential. Wait for the number of seconds in Retry-After.

List webhooks#

Every webhook your team has, newest first, each with the time and status code of its most recent delivery. Not paginated — a team has at most 20.

GEThttps://api.poststack.dev/webhooks
Request
const { webhooks } = await poststack.webhooks.list();
{
  "webhooks": [
    {
      "id": 12,
      "teamId": 3051,
      "url": "https://yourdomain.com/webhooks/poststack",
      "events": ["email.delivered", "email.bounced", "email.complained"],
      "active": true,
      "consecutiveFailures": 0,
      "disabledAt": null,
      "disabledReason": null,
      "secretRotatedAt": null,
      "previousSecretExpiresAt": null,
      "createdAt": "2026-09-29T10:00:00.000Z",
      "updatedAt": "2026-09-29T10:00:00.000Z",
      "signingSecret": "whsec_••••...",
      "lastDeliveryAt": "2026-09-29T11:02:17.440Z",
      "lastDeliveryStatus": 200
    }
  ]
}

Response fields

webhooks[]object[]
Each has the fields of webhook in Create a webhook, plus the three below.
webhooks[].signingSecretstring
A mask, whsec_••••... — never the secret itself.
webhooks[].lastDeliveryAtstring (ISO 8601) | null
When the most recent delivery was created, or null if none.
webhooks[].lastDeliveryStatusinteger | null
The HTTP status of that delivery’s latest attempt; null while it has no response yet, or when the request never got one.

Status codes

StatusMeaning
200The list (possibly empty).
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.

Retrieve a webhook#

One webhook. The signing secret is not included in any form.

GEThttps://api.poststack.dev/webhooks/:id
Request
// The SDK unwraps { webhook }.
const webhook = await poststack.webhooks.get(12);
{
  "webhook": {
    "id": 12,
    "teamId": 3051,
    "url": "https://yourdomain.com/webhooks/poststack",
    "events": ["email.delivered", "email.bounced", "email.complained"],
    "active": true,
    "consecutiveFailures": 0,
    "disabledAt": null,
    "disabledReason": null,
    "secretRotatedAt": null,
    "previousSecretExpiresAt": null,
    "createdAt": "2026-09-29T10:00:00.000Z",
    "updatedAt": "2026-09-29T10:00:00.000Z"
  }
}

Path parameters

idintegerrequired
The webhook’s ID. A non-numeric value is rejected with 400.

Response fields

webhook.idinteger
The webhook’s ID, used in every other path.
webhook.teamIdinteger
The team that owns it.
webhook.urlstring
Where deliveries are POSTed.
webhook.eventsstring[]
The subscribed event names.
webhook.activeboolean
false when you disabled it or PostStack auto-disabled it. Inactive webhooks receive nothing: new events are not queued, and deliveries already queued or retrying are skipped when their turn comes (they stay failed, so a batch replay after re-enabling resends them).
webhook.consecutiveFailuresinteger
Events in a row that exhausted every retry. Reset to 0 by any successful delivery.
webhook.disabledAtstring (ISO 8601) | null
When PostStack auto-disabled the webhook, or null.
webhook.disabledReasonstring | null
Why it was auto-disabled, e.g. Auto-disabled after 20 consecutive failures; null otherwise.
webhook.secretRotatedAtstring (ISO 8601) | null
When the signing secret was last rotated; null if never.
webhook.previousSecretExpiresAtstring (ISO 8601) | null
While this is in the future, a rotation grace window is open and deliveries are signed with both secrets.
webhook.createdAtstring (ISO 8601)
When it was created.
webhook.updatedAtstring (ISO 8601)
When it last changed.

Status codes

StatusMeaning
200The webhook.
400The ID is not a positive integer.
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.
404Webhook not found — no webhook with this ID belongs to your team.

Update a webhook#

Change the URL, replace the event list, or disable and re-enable the webhook. Send only the fields you want to change.

PATCHhttps://api.poststack.dev/webhooks/:id
Request
// Returns the updated webhook (the SDK unwraps { webhook }).
const webhook = await poststack.webhooks.update(12, {
  events: ['email.delivered', 'email.bounced', 'email.complained', 'email.opened'],
  enabled: true,
});
{
  "webhook": {
    "id": 12,
    "teamId": 3051,
    "url": "https://yourdomain.com/webhooks/poststack",
    "events": ["email.delivered", "email.bounced", "email.complained", "email.opened"],
    "active": true,
    "consecutiveFailures": 0,
    "disabledAt": null,
    "disabledReason": null,
    "secretRotatedAt": null,
    "previousSecretExpiresAt": null,
    "createdAt": "2026-09-29T10:00:00.000Z",
    "updatedAt": "2026-09-29T14:20:05.000Z"
  }
}

Path parameters

idintegerrequired
The webhook’s ID. A non-numeric value is rejected with 400.

Body parameters

urlstring
A new URL. Checked exactly as on create.
eventsstring[]
Replaces the whole list — it is not merged with the current one. At least one event.
enabledboolean
false stops new events. true re-enables the webhook and clears consecutiveFailures, disabledAt and disabledReason, which is how you bring back one PostStack auto-disabled. Events that fired while it was disabled are not queued for it.

Response fields

webhook.idinteger
The webhook’s ID, used in every other path.
webhook.teamIdinteger
The team that owns it.
webhook.urlstring
Where deliveries are POSTed.
webhook.eventsstring[]
The subscribed event names.
webhook.activeboolean
false when you disabled it or PostStack auto-disabled it. Inactive webhooks receive nothing: new events are not queued, and deliveries already queued or retrying are skipped when their turn comes (they stay failed, so a batch replay after re-enabling resends them).
webhook.consecutiveFailuresinteger
Events in a row that exhausted every retry. Reset to 0 by any successful delivery.
webhook.disabledAtstring (ISO 8601) | null
When PostStack auto-disabled the webhook, or null.
webhook.disabledReasonstring | null
Why it was auto-disabled, e.g. Auto-disabled after 20 consecutive failures; null otherwise.
webhook.secretRotatedAtstring (ISO 8601) | null
When the signing secret was last rotated; null if never.
webhook.previousSecretExpiresAtstring (ISO 8601) | null
While this is in the future, a rotation grace window is open and deliveries are signed with both secrets.
webhook.createdAtstring (ISO 8601)
When it was created.
webhook.updatedAtstring (ISO 8601)
When it last changed.

Status codes

StatusMeaning
200The updated webhook. An empty body changes nothing and returns it as is.
400Validation failed, or the new URL was refused (same messages as create).
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.
404Webhook not found — no webhook with this ID belongs to your team.
429More than 10 calls a minute to this endpoint with the same credential. Wait for the number of seconds in Retry-After.

Delete a webhook#

Deletes the webhook and its whole delivery history. Nothing more is sent to it — including events already queued or waiting for a retry, which are dropped at send time.

DELETEhttps://api.poststack.dev/webhooks/:id
Request
await poststack.webhooks.delete(12);
{
  "success": true
}

Path parameters

idintegerrequired
The webhook’s ID. A non-numeric value is rejected with 400.

Response fields

successboolean
Always true.

Status codes

StatusMeaning
200Deleted.
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.
404Webhook not found — no webhook with this ID belongs to your team.
429More than 10 calls a minute to this endpoint with the same credential. Wait for the number of seconds in Retry-After.

Send a test event#

Queues one delivery of type test to the webhook, signed like any other. A 200 means it was queued, not that your endpoint accepted it — check the delivery log for the result. It is retried like any other delivery. A disabled webhook answers 409; enable it first.

POSThttps://api.poststack.dev/webhooks/:id/test
Request
await poststack.webhooks.test(12);

// Then look at the newest delivery for the outcome:
// GET /webhooks/12/deliveries → deliveries[0].statusCode / responseBody
{
  "success": true
}

Path parameters

idintegerrequired
The webhook’s ID. A non-numeric value is rejected with 400.

Response fields

successboolean
Always true.

Status codes

StatusMeaning
200The test delivery was queued.
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.
404Webhook not found — no webhook with this ID belongs to your team.
409The webhook is disabled. Enable it before testing.
429More than 10 calls a minute to this endpoint with the same credential. Wait for the number of seconds in Retry-After.

List deliveries#

The delivery log for one webhook, newest first: one row per event sent to it, updated in place on every retry.

GEThttps://api.poststack.dev/webhooks/:id/deliveries
Request
curl "https://api.poststack.dev/webhooks/12/deliveries?page=1&per_page=50" \
  -H "Authorization: Bearer sk_live_..."
{
  "deliveries": [
    {
      "id": 90412,
      "webhookId": 12,
      "eventType": "email.bounced",
      "payload": { "type": "email.bounced", "created_at": "2026-09-29T10:05:00.120Z", "data": { … } },
      "statusCode": 500,
      "responseBody": "Internal Server Error",
      "attempts": 3,
      "nextRetryAt": "2026-09-29T10:35:12.000Z",
      "deliveredAt": null,
      "createdAt": "2026-09-29T10:05:00.121Z"
    },
    {
      "id": 90398,
      "webhookId": 12,
      "eventType": "email.delivered",
      "payload": { "type": "email.delivered", "created_at": "2026-09-29T10:00:02.114Z", "data": { … } },
      "statusCode": 200,
      "responseBody": "ok",
      "attempts": 1,
      "nextRetryAt": null,
      "deliveredAt": "2026-09-29T10:00:02.391Z",
      "createdAt": "2026-09-29T10:00:02.115Z"
    }
  ],
  "pagination": { "page": 1, "perPage": 20, "total": 156, "totalPages": 8 }
}

Path parameters

idintegerrequired
The webhook’s ID. A non-numeric value is rejected with 400.

Query parameters

pageintegerdefault 1
Page number, from 1.
per_pageintegerdefault 20
1–100.

Response fields

deliveries[].idinteger
The delivery ID, used to replay it.
deliveries[].webhookIdinteger
The webhook it belongs to.
deliveries[].eventTypestring
The event name, or test.
deliveries[].payloadobject
The envelope that was (or will be) POSTed.
deliveries[].statusCodeinteger | null
Your endpoint’s status on the latest attempt; null before the first attempt, or when the request got no response (timeout, connection refused, DNS failure).
deliveries[].responseBodystring | null
The first 1,000 characters of your endpoint's response; for a request that got no response, Delivery failed: <reason>.
deliveries[].attemptsinteger
Attempts made so far, up to 8. A replay resets it to 0.
deliveries[].nextRetryAtstring (ISO 8601) | null
When the next retry is scheduled; null once it succeeded, was rejected or gave up.
deliveries[].deliveredAtstring (ISO 8601) | null
When your endpoint answered with a 2xx; null otherwise.
deliveries[].createdAtstring (ISO 8601)
When the event was dispatched.
paginationobject
page, perPage, total, totalPages.

Status codes

StatusMeaning
200The page of deliveries.
400A non-numeric ID, or page / per_page out of range.
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.
404Webhook not found — no webhook with this ID belongs to your team.

Replay a delivery#

Sends one delivery again from attempt 0 — whatever its outcome was, including a delivery that already succeeded. It goes to the webhook's current URL, signed with its current secret(s); the payload is the stored one, unchanged.

POSThttps://api.poststack.dev/webhooks/:id/deliveries/:did/replay
Request
await poststack.webhooks.replay(12, 90412);
{
  "success": true
}

Path parameters

idintegerrequired
The webhook’s ID. A non-numeric value is rejected with 400.
didintegerrequired
The delivery ID from List deliveries. It must belong to one of your team’s webhooks.

Response fields

successboolean
Always true.

Status codes

StatusMeaning
200Re-queued.
400An ID is not a positive integer.
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.
404Delivery not found.
409The webhook is disabled. Enable it before replaying.
429More than 10 calls a minute to this endpoint with the same credential. Wait for the number of seconds in Retry-After.

Replay failed deliveries#

Re-queues every failed delivery of one webhook, oldest first — the call to make after fixing an endpoint that was down, because replaying in order is what makes your state come out right. A delivery counts as failed when it has no 2xx yet, which includes deliveries still waiting for a retry and ones your endpoint rejected with 406. Deliveries that succeeded are never re-sent. At most 1,000 per call: repeat while remaining is above 0. Every field is optional; an empty body replays everything that failed.

POSThttps://api.poststack.dev/webhooks/:id/deliveries/batch-replay
Request
// Everything that failed on this webhook:
let { remaining } = await poststack.webhooks.batchReplay(12);

// Only bounces from the last two hours:
await poststack.webhooks.batchReplay(12, { withinMinutes: 120, eventType: 'email.bounced' });

// remaining > 0: the 1,000 cap cut the batch short — call again.
{
  "replayed": 42,
  "remaining": 0
}

Path parameters

idintegerrequired
The webhook’s ID. A non-numeric value is rejected with 400.

Body parameters

withinMinutesinteger
Only deliveries created in the last this-many minutes. 1–43200 (30 days).
eventTypestring
Only this event, e.g. email.bounced. 1–64 characters.
limitintegerdefault 1000
How many to replay in this call. 1–1000.

Response fields

replayedinteger
How many deliveries were re-queued.
remaininginteger
How many matched but were left for the next call. 0 means the backlog is cleared.

Status codes

StatusMeaning
200Re-queued (replayed may be 0).
400Validation failed.
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.
404Webhook not found — no webhook with this ID belongs to your team.
409The webhook is disabled. Enable it before replaying.
429More than 3 calls a minute to this endpoint with the same credential. Wait for the number of seconds in Retry-After.

Rotate the signing secret#

Issues a new signing secret and returns it once. By default the old secret keeps working for 24 hours, and every delivery in that window is signed with both, so you can deploy the new secret without losing events. Rotating again inside a window retires the secret being replaced; the one before it is not revived.

POSThttps://api.poststack.dev/webhooks/:id/rotate-secret
Request
const { signingSecret } = await poststack.webhooks.rotateSecret(12);
// Store it now. The old secret keeps verifying for 24h.

// The old secret leaked — cut over immediately:
await poststack.webhooks.rotateSecret(12, { graceHours: 0 });
{
  "webhook": {
    "id": 12,
    "teamId": 3051,
    "url": "https://yourdomain.com/webhooks/poststack",
    "events": ["email.delivered", "email.bounced", "email.complained"],
    "active": true,
    "consecutiveFailures": 0,
    "disabledAt": null,
    "disabledReason": null,
    "secretRotatedAt": "2026-09-29T15:00:00.000Z",
    "previousSecretExpiresAt": "2026-09-30T15:00:00.000Z",
    "createdAt": "2026-09-29T10:00:00.000Z",
    "updatedAt": "2026-09-29T15:00:00.000Z"
  },
  "signingSecret": "whsec_8b1e4d7a0c3f6e9b2d5a8c1f4e7b0d3a6c9f2e5b8d1a4c7f0e3b6d9a2c5f8e1b"
}

Path parameters

idintegerrequired
The webhook’s ID. A non-numeric value is rejected with 400.

Body parameters

graceHoursintegerdefault 24
How long the old secret stays valid, 0–168. Use 0 if the old secret leaked: it stops being accepted immediately. The body may be omitted.

Response fields

webhook.idinteger
The webhook’s ID, used in every other path.
webhook.teamIdinteger
The team that owns it.
webhook.urlstring
Where deliveries are POSTed.
webhook.eventsstring[]
The subscribed event names.
webhook.activeboolean
false when you disabled it or PostStack auto-disabled it. Inactive webhooks receive nothing: new events are not queued, and deliveries already queued or retrying are skipped when their turn comes (they stay failed, so a batch replay after re-enabling resends them).
webhook.consecutiveFailuresinteger
Events in a row that exhausted every retry. Reset to 0 by any successful delivery.
webhook.disabledAtstring (ISO 8601) | null
When PostStack auto-disabled the webhook, or null.
webhook.disabledReasonstring | null
Why it was auto-disabled, e.g. Auto-disabled after 20 consecutive failures; null otherwise.
webhook.secretRotatedAtstring (ISO 8601) | null
When the signing secret was last rotated; null if never.
webhook.previousSecretExpiresAtstring (ISO 8601) | null
While this is in the future, a rotation grace window is open and deliveries are signed with both secrets.
webhook.createdAtstring (ISO 8601)
When it was created.
webhook.updatedAtstring (ISO 8601)
When it last changed.
signingSecretstring
The new plaintext secret. Never shown again.

Status codes

StatusMeaning
200Rotated.
400graceHours is not an integer from 0 to 168.
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.
404Webhook not found — no webhook with this ID belongs to your team.
429More than 10 calls a minute to this endpoint with the same credential. Wait for the number of seconds in Retry-After.

Get delivery stats#

Delivery success across all of your team's webhooks, per UTC day, with days that had no deliveries filled with zeros. A delivery counts as a success when its latest attempt got a 2xx and as a failure when it got anything else; deliveries with no response recorded are left out.

GEThttps://api.poststack.dev/webhooks/stats
Request
curl "https://api.poststack.dev/webhooks/stats?days=7" \
  -H "Authorization: Bearer sk_live_..."
{
  "daily": [
    { "date": "2026-09-23", "success": 1204, "failed": 3 },
    { "date": "2026-09-24", "success": 1187, "failed": 0 },
    { "date": "2026-09-25", "success": 1310, "failed": 12 },
    { "date": "2026-09-26", "success": 998, "failed": 0 },
    { "date": "2026-09-27", "success": 640, "failed": 0 },
    { "date": "2026-09-28", "success": 702, "failed": 1 },
    { "date": "2026-09-29", "success": 415, "failed": 0 }
  ],
  "totals": { "total": 6472, "success": 6456, "failed": 16, "successRate": 99.8 }
}

Query parameters

daysintegerdefault 30
The window: 7, 30 or 90. Any other value from 1 to 365 is treated as 30; above 365 is a 400.

Response fields

daily[]object[]
One entry per day, oldest first: date (YYYY-MM-DD), success, failed.
totals.totalinteger
success + failed over the window.
totals.successinteger
Successful deliveries.
totals.failedinteger
Failed deliveries.
totals.successRatenumber
A percentage with one decimal; 100 when there were no deliveries.

Status codes

StatusMeaning
200The stats.
400days is not an integer from 1 to 365.
401Missing, invalid or revoked API key or access token.
403insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended.

Troubleshooting#

Every delivery fails signature verification

You are almost certainly hashing a parsed-and-re-serialized body. Read the raw bytes (express.raw(), request.get_data(), io.ReadAll(r.Body)) and HMAC those. Also check you are using this webhook's secret — every webhook has its own — and the whole string, whsec_ prefix included.

Verification broke right after I rotated the secret

During the grace window the header carries two signatures. Your verifier is comparing the whole header, or only the first element, against one HMAC. Split it on commas and accept a match on any element — or use an SDK's verify.

My endpoint returns 200, but the delivery log shows a failure

Look at statusCode and responseBody on the delivery. A 301/302 means the URL redirects (for example http → https, or a missing trailing slash) — register the final URL. A null status with Delivery failed: … means no response arrived within 10 seconds, or the connection failed.

The webhook stopped receiving events

GET /webhooks/:id: if active is false and disabledAt is set, PostStack auto-disabled it after 20 events in a row failed. Fix the endpoint, re-enable it with { "enabled": true }, then call POST /webhooks/:id/deliveries/batch-replay to resend the events that failed before it was disabled.

I received the same event twice

A delivery that timed out is retried even if your endpoint finished processing it, and replays resend events on purpose. Deduplicate on type + created_at + the resource ID in data, which are identical on every attempt.

Creating the webhook fails: “Webhook URL resolves to a private IP address”

PostStack only delivers to public addresses, so localhost and LAN URLs are refused. To receive events on your machine during development, expose it through a tunnel with a public hostname and register that URL.

Next steps

Was this page helpful?

Related

Webhooks overviewHMAC-signed event delivery with retries and replay.

Framework guides

Background reading