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). |
clicked | The recipient followed a link (when click tracking is enabled). Fires on every click, not only the first; data.url is the destination. A click also produces an opened event if the message had not registered one. |
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. |
domain | string | The sending domain the message left from. |
domain_id | string (UUID) | That domain's id, the same one the white-label API addresses it by. Present on every event, so a consumer with an endpoint per domain can pick the right signing secret before reading anything else. |
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. |
original_message_id | string | The Message-ID your client wrote on a message submitted over SMTP, exactly as sent (angle brackets included). Always present; "" for API sends and for SMTP messages that carried none. |
email | string | Recipient address. |
tags | object | The labels you set on the send, so you can route an event without looking the message up. Always present; {} when the send carried none. |
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 rewritten before the message is signed and queued, so your value does not reach the recipient. It is kept, though: every webhook carries it as original_message_id, and the read API finds the message by it — GET /external/emails/?original_message_id=<…>. 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 is never a webhook's message_id — and the recipient's mail client threads against ours, so it will not come back in an In-Reply-To either.
Over SMTP, join on original_message_id plus email: a message sent to several recipients becomes one message per recipient, and every copy carries the same original_message_id. Over the API, store the message_id the send endpoint returns — it is also on the message row in Emails and in the CSV export — and pass an idempotency_key, which is what actually stops a retried request from sending twice.
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",
"domain": "yourdomain.com",
"domain_id": "0f2b8c14-9a3d-4e57-8b6f-2c1d0e9a7b53",
"message_id": "<2f1c8e90-9b4d-4c3a-aa10-5e7d2c1b9f01@yourdomain.com>",
"original_message_id": "",
"email": "user@example.com",
"tags": { "stream": "password-reset" },
"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",
"domain": "yourdomain.com",
"domain_id": "0f2b8c14-9a3d-4e57-8b6f-2c1d0e9a7b53",
"message_id": "<2f1c8e90-9b4d-4c3a-aa10-5e7d2c1b9f01@yourdomain.com>",
"original_message_id": "",
"email": "user@example.com",
"tags": { "stream": "password-reset" },
"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",
"domain": "yourdomain.com",
"domain_id": "0f2b8c14-9a3d-4e57-8b6f-2c1d0e9a7b53",
"message_id": "<2f1c8e90-9b4d-4c3a-aa10-5e7d2c1b9f01@yourdomain.com>",
"original_message_id": "",
"email": "user@example.com",
"tags": { "stream": "password-reset" },
"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.
Auto-pause. After 5 failed attempts in a row (across all deliveries to an endpoint) the endpoint is paused and the domain owner is emailed. While paused it receives no new events; deliveries already waiting for a retry are held. Fix the endpoint, check it with Send test event, then press Resume in the control panel. Any successful delivery resets the count.
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.