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: Postwing-Webhooks/1.0. The body is sent minified (no whitespace); examples below are pretty-printed for readability. Every request carries four headers:

HeaderValue
X-Postwing-SignatureHex HMAC-SHA256 of {timestamp}.{body} (see below).
X-Postwing-TimestampUnix epoch seconds at signing time. Regenerated per attempt.
X-Postwing-EventThe event type, e.g. delivered — lets you route without parsing the body.
X-Postwing-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: Postwing-Webhooks/1.0
X-Postwing-Event: delivered
X-Postwing-Delivery: a1b2c3d4-0001-4f3a-9c2e-7b6d5e4f3a21
X-Postwing-Timestamp: 1782639673
X-Postwing-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 original email's Message-ID, format <[email protected]>.
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 {}.
The envelope 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

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": "<[email protected]>",
    "email": "[email protected]",
    "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": "<[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"
    }
}

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

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-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.
  • Encoding: lowercase hex digest, with no prefix (the header is the bare digest, not sha256=…).
  • Replay protection: reject requests whose X-Postwing-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-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)

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

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-Postwing-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.