Skip to main content

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 message
  • data.message: the full message payload, including extracted fields such as otp, links, and intent

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-Signature or Postmx-Signature.
  • Use X-PostMX-Timestamp as 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 300 seconds.
  • If verification fails, the SDK raises PostMXWebhookVerificationError.
  • Log X-PostMX-Delivery-Id for attempt-level tracing.
  • Log X-PostMX-Event-Id for 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-Id as the dedupe key. It is a per-attempt identifier.
  • Persist or enqueue your internal job before returning success.
  • Return a direct 2xx response as soon as the event is safely accepted.
  • If your system cannot safely accept the event, return a non-2xx response.

Delivery behavior

  • Success is any 2xx response.
  • Redirects, timeouts, network failures, and other non-2xx responses 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, and intent, so most consumers do not need a follow-up API read.

Retry behavior by plan

  • free: no push webhooks.
  • starter and early_ltd: push delivery, but no automatic retries after a failed attempt.
  • pro: push delivery with retries at 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours, and 24 hours.

When to use webhooks vs polling

  • Use webhooks for production event processing and server-to-server integrations.
  • Use waitForMessage() or wait_for_message() when you want a simple test helper and do not want to host a public endpoint.