Docs / Webhooks

Webhooks

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.

How it works

  1. You create one or more endpoints for a domain and choose which events each one receives.
  2. When a subscribed event happens, Postwing builds a JSON payload and POSTs it to your endpoint URL, signed with that endpoint's secret.
  3. Your app verifies the signature, returns a 2xx, and processes the event.
  4. If the request fails, Postwing retries with an increasing backoff until it succeeds or the schedule is exhausted.

Managing endpoints in the dashboard

Open your domain and go to Webhooks → Endpoints. From there you can:

  • Add an endpoint — enter the destination URL, pick the events it should receive, and optionally a description. The endpoint is enabled by default.
  • View the signing secret — a 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.
  • Send a test event — delivers a sample delivered event so you can confirm your endpoint receives and verifies signed requests.
  • Rotate the secret — issues a new secret and immediately invalidates the previous one.
  • Enable / disable — a disabled endpoint receives nothing.
  • Edit or delete the endpoint and its subscribed events.

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 types

EventMeaning
deliveredThe receiving mail server accepted the message.
deferredTemporary failure (e.g. greylisting); Postwing will retry sending.
bouncedHard failure — the message was rejected and will not be retried.
complainedRejected as spam / by a reputation or policy block (a 5xx spam-class failure).
droppedSuppressed before sending (e.g. the recipient is on your suppression list).
openedThe recipient opened the message (when open tracking is enabled).
unsubscribedThe recipient unsubscribed.

The request

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:

HeaderValue
X-Webhook-SignatureHex HMAC-SHA256 of {timestamp}.{body} (see below).
X-Webhook-TimestampUnix epoch seconds at signing time. Regenerated per attempt.
X-Webhook-EventThe event type, e.g. delivered — lets you route without parsing the body.
X-Webhook-DeliveryThe 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: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

Payload

Every event shares the same envelope; only event and the per-event data object differ.

FieldTypeNotes
event_idstring (UUID)Unique per delivery; your idempotency key. The same value is re-sent on retries.
eventstringOne of the event types above.
message_idstringThe Message-ID Postwing assigned to the message when you sent it — <uuid@yourdomain.com>, angle brackets included. Not a value you supplied; see below.
emailstringRecipient address.
timestampstring (ISO-8601)When the event was emitted, UTC with microseconds, e.g. 2026-06-24T09:41:13.482921+00:00.
dataobjectEvent-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:

  • SMTP relay. Whatever your mail library put in 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.
  • REST API and SDK. 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.

The envelope 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

Eventdata keys
deliveredsmtp_code, mx_host
deferredsmtp_code, message, mx_host, next_retry_at, attempts
bounced / complainedsmtp_code, message, mx_host
droppedreason
unsubscribedreason
opened(empty)

smtp_code and mx_host can be null — for example, a DNS-level failure has no MX host or SMTP code.

Example — delivered

json
{
    "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" }
}

Example — bounced

A hard failure. complained is byte-identical except "event": "complained", emitted when the 5xx is a spam / reputation / policy block.

json
{
    "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"
    }
}

Example — deferred

A temporary failure that will be retried; data includes next_retry_at and the attempts count.

json
{
    "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": {}.

Verifying signatures

Always verify the signature before trusting a payload. The scheme is:

  • Algorithm: HMAC-SHA256, with the endpoint's secret (the whsec_… value) as the key.
  • Signed string: the 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.
  • Encoding: lowercase hex digest, with no prefix (the header is the bare digest, not sha256=…).
  • Replay protection: reject requests whose X-Webhook-Timestamp is more than ~300 seconds from now.
  • Compare in constant time (e.g. hmac.compare_digest).

Python

python
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)

Node.js (Express)

js
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
  },
);

Responding

  • Return any 2xx status to mark the delivery as delivered. Any other status — or a network error — counts as a failure.
  • The request times out after 10 seconds. Do the minimum synchronously (verify + enqueue) and process asynchronously so you always respond in time.

Retries & backoff

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.

Best practices

  • Deduplicate on event_id (= X-Webhook-Delivery). It is stable across retries; the timestamp and signature are regenerated each attempt, so never dedupe on those.
  • Don't rely on ordering. Delivery order is not guaranteed — a retried deferred can arrive after delivered. Drive state transitions off the payload timestamp, not arrival order.
  • Subscribe narrowly. An endpoint only receives the events in its subscribed list; disabled endpoints receive nothing.
  • Use HTTPS and keep the secret server-side. Rotate it if you suspect it leaked.