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: Postwing-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-Postwing-Signature | Hex HMAC-SHA256 of {timestamp}.{body} (see below). |
X-Postwing-Timestamp | Unix epoch seconds at signing time. Regenerated per attempt. |
X-Postwing-Event | The event type, e.g. delivered — lets you route without parsing the body. |
X-Postwing-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: Postwing-Webhooks/1.0
X-Postwing-Event: delivered
X-Postwing-Delivery: a1b2c3d4-0001-4f3a-9c2e-7b6d5e4f3a21
X-Postwing-Timestamp: 1782639673
X-Postwing-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 original email's Message-ID, format <[email protected]>. |
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 {}. |
timestamp (ISO-8601 with microseconds) and the X-Postwing-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": "<[email protected]>",
"email": "[email protected]",
"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": "<[email protected]>",
"email": "[email protected]",
"timestamp": "2026-06-24T09:41:15.102004+00:00",
"data": {
"smtp_code": 550,
"message": "550 5.1.1 <[email protected]>: 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": "<[email protected]>",
"email": "[email protected]",
"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-Postwing-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-Postwing-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-Postwing-Signature"]
timestamp = headers["X-Postwing-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-Postwing-Signature");
const timestamp = req.get("X-Postwing-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-Postwing-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.