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#
Add a route that accepts POST
It must be reachable from the internet over
httporhttpsand answer with any2xxwithin 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.Register it and store the secret
POST /webhookswith the URL and the events you want. The response containssigningSecret— the only time you will see it.Verify every request
Check
X-PostStack-Signatureagainst the raw body before you trust the payload. See Signature verification below.Send yourself a test event
POST /webhooks/:id/testqueues a delivery of typetest. ThenGET /webhooks/:id/deliveriesshows 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
| Event | Fires when | Data fields |
|---|---|---|
email.scheduled | A 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.sent | PostStack 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.delivered | The 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_delayed | Handing 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.failed | The third and final send attempt failed. The message will not be retried. | email_id, from, to, subject, error |
email.bounced | The 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_bounced | The receiving server reported a temporary failure (soft bounce). | email_id, from, to, subject, bounce_message, bounce_code, bounce_category |
email.complained | A 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.opened | The 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.clicked | A tracked link was followed. Fires on every click, including automated link scanners. | email_id, url, ip_address, user_agent |
email.unsubscribed | A 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.suppressed | A 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.inbound | A 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
| Event | Fires when | Data fields |
|---|---|---|
contact.created | A 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.updated | A contact was updated through the API or the dashboard. | public_id, id, email, first_name, last_name |
contact.deleted | A contact was deleted. | public_id, id, email |
contact.unsubscribed | A 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
| Event | Fires when | Data fields |
|---|---|---|
domain.created | A domain was added. | id, name, region |
domain.updated | A domain’s settings were changed with PATCH /domains/:id or in the dashboard. | id, name |
domain.deleted | A domain was deleted. | id |
domain.verified | A DNS check found every required record and the domain’s status changed to verified. | id, name, verified_at |
domain.failed | A 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_drift | A 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_started | A DKIM key rotation began. The payload carries the new TXT record to publish. | id, name, selector, record |
domain.dkim_rotation_activated | PostStack 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_completed | The old DKIM selector was retired. If your DNS provider is connected, its record was also removed. | id, name, retired_selector |
Payloads#
Every delivery is an HTTP POST with a JSON body in the same envelope, and these request headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-PostStack-Signature | sha256=<hex>, or two comma-separated elements during a secret rotation — see Signature verification below. |
User-Agent | PostStack-Webhook/1.0 |
The envelope
typestring- The event name, e.g.
email.delivered. A delivery triggered byPOST /webhooks/:id/testhas the typetest. 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.
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).
{
"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.
{
"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.
{
"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.
{
"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.
{
"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"
}
}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.
{
"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.
{
"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.
{
"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.
{
"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).
{
"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"
}
}{
"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
{
"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
Take the raw request body
The exact bytes of the JSON body we sent. Nothing else is signed: no timestamp, no URL, no headers.
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).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:textX-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.
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 });import os
from flask import Flask, request
from poststack import PostStack
app = Flask(__name__)
client = PostStack(api_key=os.environ["POSTSTACK_API_KEY"])
@app.post("/webhooks/poststack")
def poststack_webhook():
# get_data() returns the raw bytes; request.json would re-serialize them.
ok = client.webhooks.verify(
request.get_data(),
request.headers.get("X-PostStack-Signature", ""),
os.environ["POSTSTACK_WEBHOOK_SECRET"],
)
if not ok:
return "Invalid signature", 401
event = request.get_json()
# handle event["type"] …
return "", 200package main
import (
"encoding/json"
"io"
"net/http"
"os"
poststack "github.com/getpoststack/go-sdk"
)
var client = poststack.NewClient(os.Getenv("POSTSTACK_API_KEY"))
func poststackWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if !client.Webhooks.Verify(body, r.Header.Get("X-PostStack-Signature"), os.Getenv("POSTSTACK_WEBHOOK_SECRET")) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
_ = json.Unmarshal(body, &event)
// handle event.Type …
w.WriteHeader(http.StatusOK)
}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.
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 endpoint | What PostStack does |
|---|---|
Any 2xx | Delivered. The webhook’s consecutive-failure count goes back to 0. |
406 Not Acceptable | Rejected 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 connection | Retried, up to 8 attempts in all. |
| The hostname now resolves to a private or reserved address | Blocked 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.
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.
https://api.poststack.dev/webhooksconst { 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);curl -X POST https://api.poststack.dev/webhooks \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourdomain.com/webhooks/poststack",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'{
"url": "https://yourdomain.com/webhooks/poststack",
"events": ["email.delivered", "email.bounced", "email.complained"]
}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
httporhttpsURL. 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.activebooleanfalsewhen 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
| Status | Meaning |
|---|---|
| 201 | Created. The webhook is active immediately. |
| 400 | Validation 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. |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_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). |
| 429 | More 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.
https://api.poststack.dev/webhooksconst { webhooks } = await poststack.webhooks.list();curl https://api.poststack.dev/webhooks \
-H "Authorization: Bearer sk_live_..."{
"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
webhookin 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
| Status | Meaning |
|---|---|
| 200 | The list (possibly empty). |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_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.
https://api.poststack.dev/webhooks/:id// The SDK unwraps { webhook }.
const webhook = await poststack.webhooks.get(12);curl https://api.poststack.dev/webhooks/12 \
-H "Authorization: Bearer sk_live_..."{
"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.activebooleanfalsewhen 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
| Status | Meaning |
|---|---|
| 200 | The webhook. |
| 400 | The ID is not a positive integer. |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended. |
| 404 | Webhook 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.
https://api.poststack.dev/webhooks/:id// 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,
});curl -X PATCH https://api.poststack.dev/webhooks/12 \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "enabled": true }'{
"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.
enabledbooleanfalsestops new events.truere-enables the webhook and clearsconsecutiveFailures,disabledAtanddisabledReason, 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.activebooleanfalsewhen 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
| Status | Meaning |
|---|---|
| 200 | The updated webhook. An empty body changes nothing and returns it as is. |
| 400 | Validation failed, or the new URL was refused (same messages as create). |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended. |
| 404 | Webhook not found — no webhook with this ID belongs to your team. |
| 429 | More 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.
https://api.poststack.dev/webhooks/:idawait poststack.webhooks.delete(12);curl -X DELETE https://api.poststack.dev/webhooks/12 \
-H "Authorization: Bearer sk_live_..."{
"success": true
}Path parameters
idintegerrequired- The webhook’s ID. A non-numeric value is rejected with 400.
Response fields
successboolean- Always true.
Status codes
| Status | Meaning |
|---|---|
| 200 | Deleted. |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended. |
| 404 | Webhook not found — no webhook with this ID belongs to your team. |
| 429 | More 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.
https://api.poststack.dev/webhooks/:id/testawait poststack.webhooks.test(12);
// Then look at the newest delivery for the outcome:
// GET /webhooks/12/deliveries → deliveries[0].statusCode / responseBodycurl -X POST https://api.poststack.dev/webhooks/12/test \
-H "Authorization: Bearer sk_live_..."{
"success": true
}Path parameters
idintegerrequired- The webhook’s ID. A non-numeric value is rejected with 400.
Response fields
successboolean- Always true.
Status codes
| Status | Meaning |
|---|---|
| 200 | The test delivery was queued. |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended. |
| 404 | Webhook not found — no webhook with this ID belongs to your team. |
| 409 | The webhook is disabled. Enable it before testing. |
| 429 | More 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.
https://api.poststack.dev/webhooks/:id/deliveriescurl "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
pageintegerdefault1- Page number, from 1.
per_pageintegerdefault20- 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.
paginationobjectpage,perPage,total,totalPages.
Status codes
| Status | Meaning |
|---|---|
| 200 | The page of deliveries. |
| 400 | A non-numeric ID, or page / per_page out of range. |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended. |
| 404 | Webhook 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.
https://api.poststack.dev/webhooks/:id/deliveries/:did/replayawait poststack.webhooks.replay(12, 90412);curl -X POST https://api.poststack.dev/webhooks/12/deliveries/90412/replay \
-H "Authorization: Bearer sk_live_..."{
"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
| Status | Meaning |
|---|---|
| 200 | Re-queued. |
| 400 | An ID is not a positive integer. |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended. |
| 404 | Delivery not found. |
| 409 | The webhook is disabled. Enable it before replaying. |
| 429 | More 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.
https://api.poststack.dev/webhooks/:id/deliveries/batch-replay// 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.curl -X POST https://api.poststack.dev/webhooks/12/deliveries/batch-replay \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "withinMinutes": 120 }'{
"withinMinutes": 120,
"eventType": "email.bounced"
}{
"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. limitintegerdefault1000- 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
| Status | Meaning |
|---|---|
| 200 | Re-queued (replayed may be 0). |
| 400 | Validation failed. |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended. |
| 404 | Webhook not found — no webhook with this ID belongs to your team. |
| 409 | The webhook is disabled. Enable it before replaying. |
| 429 | More 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.
https://api.poststack.dev/webhooks/:id/rotate-secretconst { 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 });curl -X POST https://api.poststack.dev/webhooks/12/rotate-secret \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "graceHours": 24 }'{
"graceHours": 24
}{
"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
graceHoursintegerdefault24- How long the old secret stays valid, 0–168. Use
0if 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.activebooleanfalsewhen 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
| Status | Meaning |
|---|---|
| 200 | Rotated. |
| 400 | graceHours is not an integer from 0 to 168. |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_scope: the key has sending-only access, or the OAuth token lacks webhooks:manage. Also returned when the team is suspended. |
| 404 | Webhook not found — no webhook with this ID belongs to your team. |
| 429 | More 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.
https://api.poststack.dev/webhooks/statscurl "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
daysintegerdefault30- The window:
7,30or90. Any other value from 1 to 365 is treated as 30; above 365 is a400.
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
| Status | Meaning |
|---|---|
| 200 | The stats. |
| 400 | days is not an integer from 1 to 365. |
| 401 | Missing, invalid or revoked API key or access token. |
| 403 | insufficient_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
Emails
Send the messages whose delivery, bounce and engagement events you receive here.
Test mode
Fire email.sent and email.delivered at your handler without sending real mail.
Inbound email
Receive replies and fetch the full message behind an email.inbound event.
Notification channels
Route the same events to Slack, Discord or Telegram without writing a handler.