Webhooks let Postwing notify your application in real time about what happens to the email you send — when a message is delivered, bounced, deferred for a retry, marked as spam, dropped, opened, or when a recipient unsubscribes. Instead of polling our API, you register an HTTPS endpoint and we POST a signed JSON payload to it as each event occurs.
POSTs it to your endpoint URL, signed with that endpoint's secret.2xx, and processes the event.Open your domain and go to Webhooks → Endpoints. From there you can:
whsec_… value is generated when the endpoint is created. It is shown only once, so copy it immediately and store it securely; use it to verify signatures.delivered event so you can confirm your endpoint receives and verifies signed requests.The Webhooks → Deliveries tab shows every delivery attempt with its status, attempt count, HTTP response code, the last error, and the full payload — useful for debugging.
| Event | Meaning |
|---|---|
delivered | The receiving mail server accepted the message. |
deferred | Temporary failure (e.g. greylisting); Postwing will retry sending. |
bounced | Hard failure — the message was rejected and will not be retried. |
complained | Rejected as spam / by a reputation or policy block (a 5xx spam-class failure). |
dropped | Suppressed before sending (e.g. the recipient is on your suppression list). |
opened | The recipient opened the message (when open tracking is enabled). |
unsubscribed | The recipient unsubscribed. |
Each event is sent as an HTTP POST with Content-Type: application/json and User-Agent: Webhooks/1.0. The body is sent minified (no whitespace); examples below are pretty-printed for readability. Every request carries four headers:
| Header | Value |
|---|---|
X-Webhook-Signature | Hex HMAC-SHA256 of {timestamp}.{body} (see below). |
X-Webhook-Timestamp | Unix epoch seconds at signing time. Regenerated per attempt. |
X-Webhook-Event | The event type, e.g. delivered — lets you route without parsing the body. |
X-Webhook-Delivery | The delivery id — identical to event_id in the body. |
POST /webhooks/postwing HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: Webhooks/1.0
X-Webhook-Event: delivered
X-Webhook-Delivery: a1b2c3d4-0001-4f3a-9c2e-7b6d5e4f3a21
X-Webhook-Timestamp: 1782639673
X-Webhook-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08Every event shares the same envelope; only event and the per-event data object differ.
| Field | Type | Notes |
|---|---|---|
event_id | string (UUID) | Unique per delivery; your idempotency key. The same value is re-sent on retries. |
event | string | One of the event types above. |
message_id | string | The Message-ID Postwing assigned to the message when you sent it — <uuid@yourdomain.com>, angle brackets included. Not a value you supplied; see below. |
email | string | Recipient address. |
timestamp | string (ISO-8601) | When the event was emitted, UTC with microseconds, e.g. 2026-06-24T09:41:13.482921+00:00. |
data | object | Event-specific fields; may be {}. |
message_id is ours, not yours Postwing sets the Message-ID itself on every outgoing message — <{email uuid}@{your domain}> — and a value you supplied is replaced, not preserved. That is true on both send paths:
Message-ID is deleted and rewritten before the message is signed and queued. Your value does not reach the recipient and is not recorded anywhere. Message-ID is not on the allowlist for caller-supplied headers at all — the only ones accepted are In-Reply-To, References, Reply-To and your own X-* headers. Anything else is refused with a 400 naming it. If your application matches replies and bounces on a Message-ID it generated, that key will never appear in a webhook — and the recipient's mail client threads against ours, so it will not come back in an In-Reply-To either.
Store the message_id we return and join on that. The send endpoints answer it, it is on the message row in Emails and in the CSV export, and it is the value in every delivered / bounced / complained event. To carry your own identifier through the round trip instead, send it as an X- header — those are passed through untouched.
The mirror image applies to inbound: there, message_id is the sender's own header, verbatim and unmodified, because that message is not ours to rewrite.
timestamp (ISO-8601 with microseconds) and the X-Webhook-Timestamp header (Unix epoch seconds) are different formats serving different purposes — the header value is the one used to compute the signature. data fields per event| Event | data keys |
|---|---|
delivered | smtp_code, mx_host |
deferred | smtp_code, message, mx_host, next_retry_at, attempts |
bounced / complained | smtp_code, message, mx_host |
dropped | reason |
unsubscribed | reason |
opened | (empty) |
smtp_code and mx_host can be null — for example, a DNS-level failure has no MX host or SMTP code.
delivered{
"event_id": "a1b2c3d4-0001-4f3a-9c2e-7b6d5e4f3a21",
"event": "delivered",
"message_id": "<2f1c8e90-9b4d-4c3a-aa10-5e7d2c1b9f01@yourdomain.com>",
"email": "user@example.com",
"timestamp": "2026-06-24T09:41:13.482921+00:00",
"data": { "smtp_code": 250, "mx_host": "mx1.example.com" }
}bouncedA hard failure. complained is byte-identical except "event": "complained", emitted when the 5xx is a spam / reputation / policy block.
{
"event_id": "b7e2c1a0-0002-4d8b-8f3c-1a2b3c4d5e6f",
"event": "bounced",
"message_id": "<2f1c8e90-9b4d-4c3a-aa10-5e7d2c1b9f01@yourdomain.com>",
"email": "user@example.com",
"timestamp": "2026-06-24T09:41:15.102004+00:00",
"data": {
"smtp_code": 550,
"message": "550 5.1.1 <user@example.com>: Recipient address rejected: User unknown",
"mx_host": "mx1.example.com"
}
}deferredA temporary failure that will be retried; data includes next_retry_at and the attempts count.
{
"event_id": "c3d4e5f6-0003-4a1b-9c2d-3e4f5a6b7c8d",
"event": "deferred",
"message_id": "<2f1c8e90-9b4d-4c3a-aa10-5e7d2c1b9f01@yourdomain.com>",
"email": "user@example.com",
"timestamp": "2026-06-24T09:41:14.220511+00:00",
"data": {
"smtp_code": 451,
"message": "451 4.7.1 Greylisted, try again later",
"mx_host": "mx1.example.com",
"next_retry_at": "2026-06-24T09:43:14.220000+00:00",
"attempts": 1
}
}dropped carries "data": { "reason": "recipient in suppression list" }, unsubscribed carries a reason string that varies by unsubscribe path (e.g. "Unsubscribed from Mail"), and opened has an empty "data": {}.
Always verify the signature before trusting a payload. The scheme is:
whsec_… value) as the key.X-Webhook-Timestamp value, a literal ., then the raw request body — i.e. "{timestamp}.{raw_body}". Verify against the exact bytes received, before JSON-parsing or re-serializing.sha256=…).X-Webhook-Timestamp is more than ~300 seconds from now.hmac.compare_digest).import hashlib
import hmac
import time
WEBHOOK_SECRET = "whsec_..." # the endpoint secret (shown on create / rotate)
TOLERANCE = 300 # seconds, replay protection
def verify(headers, raw_body: str) -> bool:
signature = headers["X-Webhook-Signature"]
timestamp = headers["X-Webhook-Timestamp"]
# 1. Reject stale requests (replay protection)
if abs(time.time() - int(timestamp)) > TOLERANCE:
return False
# 2. Recompute the signature over "{timestamp}.{raw_body}".
# Use the EXACT bytes you received — do not re-serialize the JSON.
signed = f"{timestamp}.{raw_body}".encode()
expected = hmac.new(WEBHOOK_SECRET.encode(), signed, hashlib.sha256).hexdigest()
# 3. Constant-time compare
return hmac.compare_digest(expected, signature)const crypto = require("crypto");
const express = require("express");
const app = express();
const SECRET = process.env.POSTWING_WEBHOOK_SECRET; // "whsec_..."
const TOLERANCE = 300;
// IMPORTANT: read the RAW body — the signature is over the exact bytes sent.
app.post(
"/webhooks/postwing",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.get("X-Webhook-Signature");
const timestamp = req.get("X-Webhook-Timestamp");
const rawBody = req.body.toString("utf8");
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE) {
return res.sendStatus(400);
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const ok =
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!ok) return res.sendStatus(400);
const evt = JSON.parse(rawBody);
// ... handle evt.event, dedupe on evt.event_id ...
res.sendStatus(200); // any 2xx marks the delivery as delivered
},
);2xx status to mark the delivery as delivered. Any other status — or a network error — counts as a failure.On failure, Postwing retries the same delivery on an increasing backoff: 60s → 5m → 30m → 2h → 6h → 24h. After the last retry the delivery is marked permanently failed and is not retried again. The retry sweeper runs every 30s, so actual fire times are within ~30s of schedule.
event_id (= X-Webhook-Delivery). It is stable across retries; the timestamp and signature are regenerated each attempt, so never dedupe on those.deferred can arrive after delivered. Drive state transitions off the payload timestamp, not arrival order.