To test Postmark webhooks on localhost, run your webhook handler locally, expose its port with npx portpreview PORT, and register the resulting HTTPS route on the appropriate Postmark Message Stream. Protect the route with Basic Authentication or a secret custom header, validate each JSON payload, persist it idempotently, and return HTTP 200 promptly.
What Postmark sends to a webhook
Postmark makes an HTTP POST request when an email event occurs, avoiding the need to poll its API. Outbound Message Streams can report delivery, bounce, open, click, spam complaint, and subscription-change events. Inbound Message Streams post parsed incoming email to their configured inbound URL. Fields differ by event, so route by RecordType and validate the schema for that type rather than treating every payload as interchangeable.
A delivery event means the destination mail server accepted the message; it does not prove that the message appeared in the recipient's inbox. A bounce reports a delivery failure and includes classification details such as Type, TypeCode, Inactive, and CanActivate. The official Postmark webhooks overview describes protection and retries, while the bounce webhook reference documents the event fields.
Build a small local Express receiver
The following example uses Express on port 3000. It checks Basic Auth before accepting JSON, validates the minimum envelope, durably records a deduplication key, and acknowledges only after that durable write. Replace the database helpers with your own transaction or queue.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.use('/webhooks/postmark', express.json({ limit: '2mb' }));
function safeEqual(actual, expected) {
const a = Buffer.from(actual);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function authorized(req) {
const value = req.get('authorization') ?? '';
if (!value.startsWith('Basic ')) return false;
const decoded = Buffer.from(value.slice(6), 'base64').toString('utf8');
const separator = decoded.indexOf(':');
if (separator < 0) return false;
return safeEqual(decoded.slice(0, separator), process.env.POSTMARK_WEBHOOK_USER ?? '') &&
safeEqual(decoded.slice(separator + 1), process.env.POSTMARK_WEBHOOK_PASSWORD ?? '');
}
app.post('/webhooks/postmark', async (req, res) => {
if (!authorized(req)) return res.sendStatus(401);
const event = req.body;
if (typeof event?.RecordType !== 'string' ||
typeof event?.MessageID !== 'string') {
return res.status(400).json({ error: 'Invalid Postmark event' });
}
const deliveryKey = `${event.RecordType}:${event.MessageID}:${event.ID ?? ''}`;
await saveWebhookOnce({ provider: 'postmark', deliveryKey, event });
res.sendStatus(200);
});
app.listen(3000);
Use a strict allowlist for recognized fields if the event controls sensitive actions. Set a sensible request-size limit, especially for inbound webhooks or bounce events configured to include message content. Do not log complete inbound messages: they may contain personal data, authentication links, attachments, or confidential correspondence.
Expose localhost through a public HTTPS URL
- Start the receiver and verify it responds locally on the intended port.
- In a second terminal, run
npx portpreview 3000. Substitute your actual application port if it differs. - Copy the generated HTTPS origin and append the route, for example
https://YOUR-TUNNEL.portpreview.dev/webhooks/postmark. - Keep the tunnel running while Postmark sends events. If the public origin changes, update the Postmark configuration.
The tunnel solves public reachability and trusted HTTPS; it does not authenticate Postmark. Anyone who discovers the URL could send a POST, so application-layer authentication and payload validation still matter. Review the localhost tunnel security guide before exposing a handler connected to real customer data.
Configure the correct Postmark webhook
For delivery, bounce, open, click, spam complaint, or subscription-change events, sign in to Postmark, select the Server and outbound Message Stream, open its Webhooks area, add the public URL, and enable only the triggers your application handles. Dashboard labels can evolve, so follow the current interface and confirm the selected Message Stream. For inbound email, configure the inbound webhook on the Inbound Message Stream's settings; an inbound stream has its own inbound URL.
Teams that provision hooks as code can use the official Webhooks API. Its webhook object supports HttpAuth username/password credentials and optional HttpHeaders, as well as trigger settings. API calls that manage hooks use the X-Postmark-Server-Token; that server token is for calling Postmark's API and should not be confused with credentials sent by Postmark to your receiver.
Postmark authentication is not cryptographic signing
This distinction prevents a common implementation error: Postmark's current documentation says it does not support HMAC webhook signature verification. There is no Postmark signing secret with which your handler can reconstruct a digest of the raw request body. Do not look for an X-Postmark-Signature header or copy a signature verifier from another email provider.
Postmark documents HTTP Basic Authentication and IP allowlisting for webhook protection. The Webhooks API also exposes custom HTTP headers. Basic Auth or a secret header authenticates possession of a shared credential, and HTTPS protects it in transit, but neither cryptographically binds the credential to the exact body. Validate payload shape and allowed values after authentication. If you use IP filtering as defense in depth, consume Postmark's current published ranges and account for the fact that the origin address can change between attempts; do not freeze addresses copied from an old article.
Prefer the API's HttpAuth fields where available instead of embedding credentials in a URL that might appear in logs or screenshots. If you configure the documented https://username:[email protected]/path form, use dedicated high-entropy credentials, encode reserved URL characters correctly, and prevent URL disclosure. Never reuse a Postmark server API token as the webhook password.
Handle delivery and bounce events by type
Move business logic out of the HTTP request path. A worker can dispatch persisted events and make each state transition independently idempotent:
async function processPostmarkEvent(event) {
switch (event.RecordType) {
case 'Delivery':
await markAcceptedByRecipientServer({
messageId: event.MessageID,
deliveredAt: event.DeliveredAt
});
break;
case 'Bounce':
await recordBounce({
bounceId: String(event.ID),
messageId: event.MessageID,
type: event.Type,
inactive: event.Inactive,
canActivate: event.CanActivate
});
break;
default:
await recordUnhandledPostmarkType(event.RecordType);
}
}
Do not infer a permanent suppression rule from a field name alone. Use Postmark's current bounce classification and your sending policy. Spam complaints and subscription changes have dedicated webhook types; they are not bounce events. Opens and clicks may occur multiple times, so decide whether you need every occurrence or only an aggregate.
Design for retries and duplicate delivery
Postmark retries when it does not receive HTTP 200. Its documented schedule differs by webhook family: bounce and inbound hooks have a longer sequence, while click, open, delivered, and subscription-change hooks use a shorter sequence. A 403 response stops retries. Consult the current overview before relying on exact intervals because provider policy can change.
A timeout can happen after your database commit but before Postmark receives the response. The repeated POST is then valid, not an attack. Put a unique constraint on a stable event key and save the inbox row in the same transaction that claims it. Postmark recommends checking MessageID; in a mixed-event endpoint, include RecordType and an event-specific identifier such as bounce ID where available so a delivery and a bounce for one message are not collapsed into one event.
Return 200 only after a minimal durable handoff. Returning first and then starting untracked background work risks losing events if the process exits. Conversely, waiting for email, CRM, and analytics APIs increases latency and duplicate retries. A database inbox or durable queue provides the useful middle ground. See webhook retry and idempotency patterns for transaction designs.
Test real event handling safely
First send a synthetic POST with curl to verify routing, authentication, validation, and persistence. Then create provider-generated events. Send a normal message to an address you control to exercise delivery. For bounce testing, use Postmark's documented testing facilities, including its black-hole test domain where applicable, rather than sending repeatedly to invented addresses. Confirm your account and Message Stream support the chosen test before depending on it.
Record the MessageID returned by your original send operation and correlate it with webhook events. Test duplicate delivery by posting the same sanitized fixture twice and asserting that side effects occur once. Test a transient server failure only in a controlled environment; make sure the eventual retry is accepted without creating a second notification or suppression.
Troubleshoot common Postmark webhook failures
No request reaches localhost
Confirm the tunnel process is still running, the configured URL includes the full route, and the local server listens on the same port passed to PortPreview. Test the public URL yourself. A tunnel landing response proves the edge is reachable, not that your POST route exists.
Every request returns 401
Compare the configured username and password with the local environment, restart the app after changing variables, and inspect whether a reverse proxy removes the Authorization header. Do not print the header value. The webhook 401/403 guide provides a safe diagnostic sequence.
Postmark keeps retrying after processing succeeds
Inspect the actual status and latency seen at the public endpoint. Postmark expects 200 specifically according to its webhook overview, so do not assume another success-like status is equivalent. Ensure errors after a database write cannot turn the response into 500, and make the write idempotent before forcing redelivery.
The payload does not match the sample
Check RecordType, the enabled trigger, and whether this is an inbound or outbound stream. Provider schemas can gain fields. Reject missing required fields, tolerate documented optional additions, and keep fixtures synchronized with the official event-specific reference.
Production security checklist
- Use HTTPS and dedicated, high-entropy Basic Auth or custom-header credentials.
- Keep server API tokens, webhook credentials, and production secrets separate.
- Rotate credentials after local testing and remove obsolete webhook URLs.
- Validate content type, body size, event type, identifiers, and required fields.
- Redact email addresses, subjects, message content, credentials, and metadata from logs.
- Apply least privilege to the worker and database records triggered by events.
- Monitor authentication failures, processing lag, duplicate rate, and dead-lettered events.
A good local test mirrors production: authenticated HTTPS ingress, narrow validation, durable idempotency, fast HTTP 200 acknowledgement, and asynchronous business work. For a broader debugging workflow, use the local webhook debugging guide.
