Verify Webhooks
Use webhooks when you want PostMX to push email.received events to your application instead of polling for new messages.
PostMX webhooks should be handled as signed HTTP callbacks. Your endpoint should be a final public HTTPS POST URL on a dedicated non-root path such as /api/webhooks/postmx. Do not use your site root, a dashboard route, or any URL that redirects. PostMX does not follow redirects, and each delivery attempt times out after 10 seconds.
Create the webhook
Node.js SDK
const result = await postmx.createWebhook({
label: "app-events",
target_url: "https://example.com/webhooks/postmx",
});
console.log(result.webhook.id);
console.log(result.signing_secret);
Python SDK
result = await client.create_webhook({
"label": "app-events",
"target_url": "https://example.com/webhooks/postmx",
})
print(result["webhook"]["id"])
print(result["signing_secret"])
CLI
postmx webhook create \
--label app-events \
--target-url https://example.com/webhooks/postmx \
--json
Save the returned signing_secret immediately. PostMX returns it once, and you need that exact value to verify future deliveries.
What PostMX sends to your server
PostMX sends a signed POST request to your webhook URL.
Request headers
X-PostMX-Timestamp: Unix timestamp used in the signed payload.X-PostMX-Signature: Primary signature header.Postmx-Signature: Compatibility copy of the same signature value.X-PostMX-Event-Id: Stable event identifier for diagnostics and payload-level deduplication.X-PostMX-Delivery-Id: Per-attempt delivery identifier for tracing retries and failures.Content-Type: application/json
Request body
The request body is JSON with:
- top-level event metadata:
id,type,created_at data.inbox: the inbox that received the messagedata.message: the full message payload, including extracted fields such asotp,links, andintent
Example payload:
{
"id": "evt_123",
"type": "email.received",
"created_at": "2026-03-27T08:15:00Z",
"data": {
"inbox": {
"id": "inb_123",
"email_address": "[email protected]",
"label": "signup-test"
},
"message": {
"id": "msg_123",
"inbox_id": "inb_123",
"inbox_email_address": "[email protected]",
"inbox_label": "signup-test",
"from_email": "[email protected]",
"to_email": "[email protected]",
"subject": "Your verification code",
"preview_text": "Use 482910 to finish signing in.",
"received_at": "2026-03-27T08:14:58Z",
"has_text_body": true,
"has_html_body": true,
"text_body": "Your verification code is 482910.",
"html_body": "<p>Your verification code is <strong>482910</strong>.</p>",
"otp": "482910",
"links": [
{
"url": "https://example.com/verify?token=abc",
"type": "verification"
}
],
"intent": "verification"
}
}
}
This payload already includes the full message body and extracted fields, so most consumers do not need a follow-up API read.
Node.js verification
import { verifyWebhookSignature } from "postmx";
const event = verifyWebhookSignature({
payload: rawBody,
signature:
(req.headers["x-postmx-signature"] as string) ??
(req.headers["postmx-signature"] as string),
timestamp: req.headers["x-postmx-timestamp"] as string,
signingSecret: process.env.POSTMX_WEBHOOK_SECRET!,
});
Python verification
from postmx import verify_webhook_signature
event = verify_webhook_signature(
payload=raw_body,
signature=request.headers.get("x-postmx-signature") or request.headers["postmx-signature"],
timestamp=request.headers["x-postmx-timestamp"],
signing_secret=signing_secret,
)
Signature model
On every incoming webhook request:
- Read the raw request body before parsing JSON.
- Use
X-PostMX-SignatureorPostmx-Signature. - Use
X-PostMX-Timestampas the timestamp value. - Verify the signature against:
v1=<base64url(hmac_sha256(signing_secret, timestamp + "." + raw_body))>
The SDK helpers above already implement this format, including timestamp tolerance checks.
Minimal verification example
import crypto from "node:crypto";
export function verifyPostmxSignature({ signingSecret, timestamp, rawBody, signatureHeader }) {
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest("base64url");
return signatureHeader === `v1=${expected}`;
}
Production handling rules
- Use the raw request body, not parsed JSON.
- Expect the signature header to start with
v1=. - The default SDK timestamp tolerance is
300seconds. - If verification fails, the SDK raises
PostMXWebhookVerificationError. - Log
X-PostMX-Delivery-Idfor attempt-level tracing. - Log
X-PostMX-Event-Idfor delivery diagnostics. - After signature verification, parse JSON and confirm
type === "email.received". - Treat deliveries as idempotent and deduplicate by the signed payload
id. - Do not use
X-PostMX-Delivery-Idas the dedupe key. It is a per-attempt identifier. - Persist or enqueue your internal job before returning success.
- Return a direct
2xxresponse as soon as the event is safely accepted. - If your system cannot safely accept the event, return a non-
2xxresponse.
Delivery behavior
- Success is any
2xxresponse. - Redirects, timeouts, network failures, and other non-
2xxresponses are treated as failed deliveries. - If both account-level and inbox-level webhooks are configured for the same inbox, the same logical message can be sent to multiple webhook endpoints.
- The payload already includes the full message body plus extracted fields such as
otp,links, andintent, so most consumers do not need a follow-up API read.
Retry behavior by plan
free: no push webhooks.starterandearly_ltd: push delivery, but no automatic retries after a failed attempt.pro: push delivery with retries at1 minute,5 minutes,15 minutes,1 hour,6 hours, and24 hours.
When to use webhooks vs polling
- Use webhooks for production event processing and server-to-server integrations.
- Use
waitForMessage()orwait_for_message()when you want a simple test helper and do not want to host a public endpoint.