Webhooks
Webhooks
Webhooks allow you to receive real-time notifications when events occur in your Postscale account. Instead of polling the API, Postscale pushes events to your server as they happen.
Supported Events
A webhook endpoint subscribes to one or more event types. Pick the subset your integration needs — endpoints only receive deliveries for the events they're explicitly subscribed to.
| Event | Description |
|---|---|
email.received | Inbound email received and parsed |
email.sent | Outbound email accepted by Postfix for delivery |
email.delivered | Outbound email accepted by the recipient's MX |
email.bounced | Outbound delivery permanently rejected (hard or soft) |
email.deferred | Outbound delivery temporarily deferred and will retry |
email.complained | Recipient marked the message as spam (FBL) |
Setting Up Webhooks
Via Dashboard
- Go to Webhooks in the dashboard
- Click Add Webhook, enter your endpoint URL (must be HTTPS), optionally pick a domain and match pattern, then click Add Webhook
- Copy the signing secret (
whsec_…) shown in the dialog — it is only displayed once. Store it in your secrets manager.
To replace a compromised secret, click Rotate on the row. The previous secret stops working immediately.
Via API
curl -X POST https://api.postscale.io/v1/webhooks \
-H "Authorization: Bearer ps_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/postscale",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'
If events is omitted, the endpoint defaults to ["email.received"] for
backwards compatibility. An explicit empty array is rejected — subscribing
to no events is almost certainly a bug.
The response contains the webhook record and the plaintext signing secret:
{
"webhook": {
"id": "...",
"webhook_url": "...",
"event_types": ["email.delivered", "email.bounced", "email.complained"],
"has_secret": true,
...
},
"secret": "whsec_aB3c…"
}
The secret field is only present in this response and in the rotate
response (POST /v1/webhooks/{id}/rotate). It is never returned by GET
calls — save it at create time.
Webhook Payload
Each delivery is a JSON POST whose body shape depends on the event. Common fields:
| Field | Type | Notes |
|---|---|---|
event | string | The event type (email.delivered, …). |
timestamp | RFC 3339 | When Postscale generated the event. |
email_id | string | Postscale's internal id for the email. |
message_id | string | The RFC 5322 Message-ID header value. |
In addition, every request carries metadata headers:
X-Postscale-Event: email.delivered
X-Postscale-Delivery-ID: 7f6b2c… (unique per attempt; use for dedup)
X-Postscale-Attempt: 1 (1-indexed; bumps on retry)
X-Postscale-Signature: t=…,v1=… (see "Verifying Webhooks" below)
Example: email.delivered
{
"event": "email.delivered",
"timestamp": "2026-04-25T10:30:00Z",
"email_id": "0c1f...-4a2e-b9d5-...",
"message_id": "<abc123@yourapp.com>",
"from": "hello@yourapp.com",
"recipient": "user@example.com",
"subject": "Welcome!"
}
Example: email.bounced
{
"event": "email.bounced",
"timestamp": "2026-04-25T10:30:00Z",
"email_id": "...",
"message_id": "<abc123@yourapp.com>",
"from": "hello@yourapp.com",
"recipient": "user@example.com",
"bounce_type": "hard",
"bounce_code": "5.1.1",
"diag_code": "550 5.1.1 The email account that you tried to reach does not exist."
}
Example: email.received (inbound)
{
"event": "email.received",
"timestamp": "2026-04-25T10:30:00Z",
"email_id": "...",
"message_id": "<unique-id@example.com>",
"from": "customer@example.com",
"to": "support@yourapp.com",
"subject": "Help with my order",
"text": "Hi, I need help...",
"html": "<p>Hi, I need help...</p>",
"headers": [
{ "name": "Message-ID", "value": "<unique-id@example.com>" },
{ "name": "In-Reply-To", "value": "<previous-id@yourapp.com>" }
],
"attachments": [
{
"filename": "screenshot.png",
"content_type": "image/png",
"size": 45678,
"url": "https://files.postscale.io/..."
}
]
}
Verifying Webhooks
Every webhook request from a webhook with a configured secret includes a
signature in the X-Postscale-Signature header. Verify it to ensure the
request came from Postscale, was not tampered with in transit, and is not
a replay.
Signature Format
X-Postscale-Signature: t=1714022400,v1=5d41402abc4b2a76b9719d911017c592...
t— Unix timestamp (seconds) when Postscale signed the delivery.v1— Lowercase hex HMAC-SHA256 of<t>.<raw_body>, signed with your webhook's signing secret.
During a rotation grace window the header
contains two v1= fields — one signed with the new secret, one with
the previous. Receivers should accept the request if either matches.
Verification Algorithm
- Parse the
t=and allv1=fields from the header. - Reject if
|now − t|exceeds your tolerance — 5 minutes is the recommended default. This blocks replay of captured deliveries. - Compute
HMAC-SHA256(secret, "<t>.<raw_body>")as a lowercase hex string. - Accept if any
v1=value matches the computed hash (constant-time compare).
Code Examples
Node.js:
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
function verifyWebhook(rawBody, header, secret) {
const parts = header.split(',');
const t = parts.find(p => p.startsWith('t=')).slice(2);
const sigs = parts.filter(p => p.startsWith('v1=')).map(p => p.slice(3));
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return sigs.some(s =>
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))
);
}
Python:
import hmac, hashlib, time
TOLERANCE_SECONDS = 300
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
parts = header.split(",")
t = next(p[2:] for p in parts if p.startswith("t="))
sigs = [p[3:] for p in parts if p.startswith("v1=")]
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
payload = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in sigs)
Compute the HMAC over the exact bytes the request was sent with — do
not re-serialize the parsed JSON. Most frameworks expose this as
req.rawBody or via a body-buffering middleware.
Rotating the Signing Secret
Rotate a webhook's signing secret at any time — for routine hygiene or in response to a suspected compromise. From the dashboard, click Rotate on the row. Via the API:
curl -X POST https://api.postscale.io/v1/webhooks/{id}/rotate \
-H "Authorization: Bearer ps_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "grace_seconds": 86400 }'
The response returns the new plaintext secret once:
{ "secret": "whsec_…", "grace_seconds": 86400 }
During the grace window (default 24 hours; pass 0 for immediate cutover,
max 604800 = 7 days), Postscale signs each delivery with both the new
secret and the previous one — the header includes two v1= fields. This
lets you update the secret in your receiver without dropping events. After
the window expires, only the new secret is used.
If you receive an event during the grace window and your stored secret is
either the old or the new one, the verification function above will accept
the request as long as you iterate the v1= fields and try each.
Retry Policy
If your endpoint returns an error (non-2xx status), Postscale retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 | 24 hours |
After 7 failed attempts, the webhook is marked as failed and no further retries are attempted.
Best Practices
Return a 2xx response as quickly as possible. Process the webhook asynchronously if needed.
- Return 200 immediately: Acknowledge receipt before processing
- Use a queue: Push webhooks to a queue for async processing
- Handle duplicates: Webhooks may be sent multiple times; use the event ID for deduplication
- Verify signatures: Always verify the webhook signature
- Log everything: Keep logs for debugging and auditing
Testing Webhooks
Local Development
For local development, use a tunnel service:
# Using ngrok
ngrok http 3000
# Use the generated URL as your webhook endpoint
# https://abc123.ngrok.io/webhooks/postscale
To exercise an outbound event, send a real email through Postscale and
watch your endpoint receive email.sent followed by either
email.delivered, email.bounced, or email.deferred. To exercise the
inbound path, send mail to a domain you've configured for inbound
processing.