A webhook is the act of Postmark making an HTTP POST request to your application’s API when an event occurs. This way Postmark is able to immediately notify you when an event occurs and your application doesn’t have to perform complicated polling of Postmark’s API to determine if something new occurred.
Note: The datetimes within webhook data will be in ISO 8601 format.
When you create or edit a webhook, Postmark tests your endpoint for each event type you've enabled and checks that it responds with a 200. This confirms your endpoint is reachable and handling events before Postmark starts sending real ones.
If an event fails the check, you'll see which event failed and the response Postmark received, so you can fix it before you depend on it.
Verification isn't only a one-time check. If an event type later fails persistently — the endpoint keeps failing across many events — Postmark marks that event type unverified and pauses delivery for it until you fix the endpoint and verify again. Because this is tracked per event type, a problem with one type (say, Bounce) doesn't pause your others (say, Delivery or Open). A paused event type surfaces as a clear status instead of a silent failure.
You can verify a webhook in three ways:
See this Allowlisting Postmark IP ranges article for Postmark IP ranges.
Note: This applies to outbound webhooks — delivery, bounce, open, click, spam complaint, and subscription change events. Inbound webhooks use separate infrastructure and aren't verified this way.
For Postmark to communicate with your application, you will need a publicly accessible URL. You’ll want to protect this URL so that malicious actors cannot manipulate your data.
If you’re using a firewall, you can configure it to only allow requests from the IP range that Postmark uses. You can find the IPs that Postmark uses for webhook requests on our support page. The origin IP address can change for each attempt.
Another easy way to protect your API is through basic HTTP authentication. Almost all web servers can be configured to require a user name and password for access to a URL. You can configure your webhook URL with basic HTTP authentication by adding the user name and password to the URL https://example.com/webhook in the following format and setting the result as the webhook URL:
https://<username>:<password>@example.com/webhook
To ensure your data is encrypted, we highly recommend using HTTPS (SSL) for your webhook URL.
Event Handler
export async function handleEmailWebhook(event: WebhookEvent) {
// Postmark doesnt sign webhooks. Protect the endpoint with
// HTTP Basic Auth and IP allowlisting (see the note below). As
// a safeguard, validate the payloads shape before acting on it
if (!event.RecordType || !event.MessageID) {
throw new Error("Unexpected webhook payload");
}
// Idempotency checks ensure events are processed once
const alreadyProcessed = await db.webhookEvents.findOne({
messageId: event.MessageID,
});
if (alreadyProcessed) {
return { status: "duplicate", messageId: event.MessageID };
}
await db.webhookEvents.create({ messageId: event.MessageID });
if (event.RecordType === "Delivery") {
return { status: "delivered", messageId: event.MessageID };
}
if (event.RecordType === "Bounce") {
return {
status: "bounced",
messageId: event.MessageID,
reason: event.Description,
};
}
// Acknowledge unhandled types too
return { status: "ignored", messageId: event.MessageID };
}
Route Handler
app.post(
"/webhooks/postmark",
express.json(),
async (req, res) => {
try {
const result = await handleEmailWebhook(req.body);
console.log("Webhook processed:", result.status, result.messageId);
res.sendStatus(200);
} catch (error) {
console.error("Webhook processing error:", error);
res.sendStatus(500);
}
}
);
Note: Postmark does not currently support HMAC webhook signature verification. The recommended approach to protect your webhook endpoint is HTTP Basic Authentication (described above) combined with allowlisting Postmark's IP ranges. We recommend validating the structure and content of incoming payloads in your handler as an additional safeguard.
A single Express route can handle both Delivery and Bounce event types from Postmark. Respond 200 once you've processed the event — or handed it to a durable queue. If processing fails, return a non-2xx status so Postmark retries. Check each MessageID against your database before acting so a retry doesn't process the same event twice.
For per-event typed handlers and full field references, see the delivery webhook → and bounce webhook → pages.
app.post("/webhooks/postmark", express.json(), async (req, res) => {
const event = req.body as { /* unchanged */ };
try {
// Idempotency: a retry of an event we've already handled is a no-op
const alreadyProcessed = await db.webhookEvents.findOne({
messageId: event.MessageID,
});
if (alreadyProcessed) return res.sendStatus(200);
await db.webhookEvents.create({ messageId: event.MessageID });
// ...Delivery and Bounce branches unchanged...
res.sendStatus(200);
} catch (error) {
console.error("Webhook processing error:", error);
res.sendStatus(500); // Postmark retries
}
});
Testing webhooks can be a difficult process. Here are two recommendations from Postmark.
A great tool for dealing with APIs (and many other things) is curl. In this context, you can use curl to make HTTP requests to your API in the same way that Postmark would make requests to your API. You can find an example curl call in each individual webhook section.
RequestBin is a great service to inspect HTTP requests. You can create a temporary RequestBin URL and use the temporary URL as your webhook URL in Postmark. RequestBin will then record the HTTP requests and allow you to inspect the HTTP requests to verify headers, JSON bodies, and other information about the request. This will provide you with information about the HTTP requests used in the webhook if you don’t have a public URL set up yet and want to start developing right away.
This covers outbound webhooks — the delivery, bounce, spam complaint, open, click, and subscription change events Postmark sends to your endpoint. Inbound webhooks are handled separately.
When Postmark delivers a webhook and the delivery fails, whether it retries depends on how your endpoint responded.
Retried (temporary failures):
Not retried — the event is dropped (permanent failures):
Redirects (3xx) are followed automatically (up to 10 hops); Postmark acts on the final response.
When a delivery is retried, Postmark uses an escalating back-off schedule — the same for every webhook type. A webhook that has been delivering normally is retried quickly at first; the longer it keeps failing, the further apart the attempts become. After the schedule is exhausted, Postmark stops retrying that individual event.
| Attempt | Delay after previous |
|---|---|
| 1 | 1 min |
| 2 | 5 min |
| 3 | 10 min |
| 4 | 10 min |
| 5 | 10 min |
| 6 | 15 min |
Every webhook request includes an X-PM-Retries-Remaining header so you can see how many attempts are left.
The retries above handle a single failed event. Separately, if an endpoint fails persistently across many events, Postmark marks that event type unverified and pauses delivery for it until you fix the endpoint and verify again. Because this is tracked per event type, a problem with one type (for example, Bounce) doesn't pause your other types (for example, Delivery or Open). See Verifying your webhook.
Retries — and timeouts where your endpoint did the work but responded too slowly — mean your endpoint may occasionally receive the same event more than once. Make your handler idempotent so a repeated delivery doesn't cause duplicate side effects. Each payload includes a unique MessageID, and each delivery carries an X-PM-Webhook-Trace-Id header that stays stable across retries of the same event. Store one (or both) on receipt and check for it before acting, so a retried or timed-out-but-successful delivery is processed only once.