One of the reasons you send emails through Postmark is to know that your messages reached the receiving server. But, your application still needs that information in an easy-to-use format that you can process. You could use the Messages API to pull data about delivery confirmations but using webhooks allows Postmark to push these events to you as they happen.
A delivery webhook will push a JSON event to your application right after Postmark records the delivery confirmation. The events are triggered when the destination email server returns an OK response, and each one is specific to a single recipient — a message sent to several people produces several events.
Delivery means the receiving server accepted your message, not that it reached the inbox. That final step is out of Postmark's hands and usually happens instantly, though a receiving server can queue it behind something like an internal firewall.
In your Postmark account — select a Server, Message Stream, and navigate to the Webhooks settings tab. Add a new webhook with your URL and toggle the Bounce type. You can also use the API.
Set Triggers.Delivery.Enabled to true to enable this event type when you create or edit webhooks. Whichever method you choose, verify everything works as expected before real events start flowing.
An example of the full JSON document that would be POSTed to your webhook URL is to the right. A brief description of some of the more interesting fields is below:
{
"RecordType": "Delivery",
"MessageStream": "outbound",
"ServerID": 23,
"MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
"Recipient": "margareth@nasa.com",
"Tag": "welcome-email",
"DeliveredAt": "2026-11-05T16:33:54.9070259Z",
"Details": "250 2.0.0 OK 1762360434 x12-20020a05 - gsmtp",
"Metadata": {
"PropA": "some value",
"PropB": "some value"
}
}
If you’re developing on your local machine or don’t have a public URL for your API, the cURL request example below sends a test webhook to your service. Replace <your-webhook-url>, run the command, and verify it accepts and processes the event as expected.
curl <your-webhook-url> \
-X POST \
-H "Content-Type: application/json" \
-d '{
"RecordType": "Delivery",
"MessageStream": "outbound",
"ServerID": 23,
"MessageID": "883953f4-6105-42a2-a16a-77a8eac79483",
"Recipient": "margareth@nasa.com",
"Tag": "welcome-email",
"DeliveredAt": "2026-11-05T16:33:54.9070259Z",
"Details": "250 2.0.0 OK 1762360434 x12-20020a05 - gsmtp",
"Metadata": {
"PropA": "some value",
"PropB": "some value"
}
}'
If you're new to setting up webhook event handlers, you can find an Express/Node/Typescript code example below to help you get started. This handler parses the delivery payload and returns a normalized result your application can act on. Protect the endpoint with HTTP Basic Authentication and IP allowlisting rather than a signature — Postmark doesn't sign webhooks.
Store an identifier for each event on receipt and check for it before processing. Postmark retries on any non-2xx response or when your endpoint times out. As a result, the same delivery event may arrive more than once. The X-PM-Webhook-Trace-Id header, when available, uniquely identifies the delivery and is the better key.
If it is not available, you can create a compound key using multiple fields including MessageID, Recipient, and DeliveredAt. A single message can be delivered to multiple recipients, so MessageID alone does not identify one delivery event.
type DeliveryEvent = {
RecordType: string;
MessageStream: string;
ServerID: number;
MessageID: string;
Recipient: string;
DeliveredAt: string;
Details: string;
Tag?: string;
Metadata: Record<string, string>;
};
export async function handleDeliveryWebhook(event: DeliveryEvent) {
// Ensure non-delivery events are not processed
if (event.RecordType !== "Delivery") {
return { status: "skipped", messageId: event.MessageID };
}
// Idempotency checks ensure events are processed once
const alreadyProcessed = await db.deliveryEvents.findOne({
messageId: event.MessageID,
});
if (alreadyProcessed) {
return { status: "duplicate", messageId: event.MessageID };
}
await db.deliveryEvents.create({
messageId: event.MessageID,
recipient: event.Recipient,
deliveredAt: event.DeliveredAt,
details: event.Details,
tag: event.Tag,
});
// Delivery means the receiving server accepted the message, not that it reached the inbox
// Spam filtering and forwarding happen after this point
await db.messages.markDelivered({
messageId: event.MessageID,
deliveredAt: event.DeliveredAt,
});
return {
status: "processed",
messageId: event.MessageID,
recipient: event.Recipient,
deliveredAt: event.DeliveredAt,
};
}
app.post(
"/webhooks/postmark/delivery",
express.json(),
async (req, res) => {
try {
const result = await handleDeliveryWebhook(req.body);
console.log("Delivery webhook processed:", result.status, result.messageId);
res.sendStatus(200);
} catch (error) {
console.error("Delivery webhook error:", error);
res.sendStatus(500);
}
}
);