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.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) |
Bounce Event Sources
email.bounced can be generated from an immediate SMTP rejection or from a
delayed delivery status notification.
For immediate failures, Postscale records the rejection from the outbound delivery log. For delayed DSNs, outbound-capable domains should publish the return-path CNAME shown in the domain DNS settings:
ps-bounces.example.com. CNAME bounces.postscale.io.
That CNAME routes delayed bounce mail back to Postscale without changing the root domain's MX records. See Outbound Service for the full DNS requirements and bounce flow.
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. |
tags | array | Tags supplied when sending the email, for outbound events. |
metadata | object | Metadata supplied when sending the email, for outbound events. |
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!",
"tags": ["welcome", "onboarding"],
"metadata": {
"user_id": "12345"
}
}
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": "550",
"diag_code": "5.1.1",
"diag_message": "The email account that you tried to reach does not exist.",
"tags": ["welcome", "onboarding"],
"metadata": {
"user_id": "12345"
}
}
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": [
{
"id": "5b7d9c0e-...",
"filename": "screenshot.png",
"content_type": "image/png",
"size": 45678,
"url": "https://api.postscale.io/v1/inbound-emails/.../attachments/5b7d9c0e-.../download"
}
]
}
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 SDK:
import { verifyWebhookSignature } from "@postscale/postscale";
const result = verifyWebhookSignature(
rawBody,
request.headers.get("x-postscale-signature"),
process.env.POSTSCALE_WEBHOOK_SECRET,
);
if (!result.valid) {
return new Response(result.message, { status: 401 });
}
The SDK verifier accepts multiple secrets for rotation windows:
const result = verifyWebhookSignature(
rawBody,
request.headers.get("x-postscale-signature"),
[
process.env.POSTSCALE_WEBHOOK_SECRET_CURRENT,
process.env.POSTSCALE_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean) as string[],
);
Node.js without SDK:
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 SDK:
from postscale import verify_webhook_signature
result = verify_webhook_signature(
raw_body,
request.headers.get("X-Postscale-Signature"),
[
current_webhook_secret,
previous_webhook_secret,
],
)
if not result.valid:
return "invalid", 401
Ruby SDK:
result = Postscale.verify_webhook_signature(
raw_body,
request.get_header("HTTP_X_POSTSCALE_SIGNATURE"),
[
current_webhook_secret,
previous_webhook_secret,
]
)
unless result.valid?
return head :unauthorized
end
PHP SDK:
use Postscale\Webhook;
$result = Webhook::verifySignature(
$rawBody,
$_SERVER["HTTP_X_POSTSCALE_SIGNATURE"] ?? null,
[
$currentWebhookSecret,
$previousWebhookSecret,
],
);
if (!$result->valid()) {
http_response_code(401);
exit;
}
FastAPI with the Python SDK:
import os
from fastapi import FastAPI, Header, Request, Response
from postscale import verify_webhook_signature
app = FastAPI()
@app.post("/postscale/webhook")
async def postscale_webhook(
request: Request,
x_postscale_signature: str | None = Header(default=None),
):
raw_body = await request.body()
result = verify_webhook_signature(
raw_body,
x_postscale_signature,
os.environ["POSTSCALE_WEBHOOK_SECRET"],
)
if not result.valid:
return Response(result.message or "invalid", status_code=401)
event = await request.json()
return {"ok": True, "type": event.get("type")}
Django with the Python SDK:
import os
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from postscale import verify_webhook_signature
@csrf_exempt
def postscale_webhook(request):
result = verify_webhook_signature(
request.body,
request.headers.get("X-Postscale-Signature"),
os.environ["POSTSCALE_WEBHOOK_SECRET"],
)
if not result.valid:
return HttpResponse(result.message or "invalid", status=401)
return JsonResponse({"ok": True})
Rails with the Ruby SDK:
class PostscaleWebhooksController < ActionController::API
def create
result = Postscale.verify_webhook_signature(
request.raw_post,
request.get_header("HTTP_X_POSTSCALE_SIGNATURE"),
ENV.fetch("POSTSCALE_WEBHOOK_SECRET")
)
return head :unauthorized unless result.valid?
event = JSON.parse(request.raw_post)
render json: { ok: true, type: event["type"] }
end
end
Laravel with the PHP SDK:
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
use Postscale\Webhook;
Route::post("/postscale/webhook", function (Request $request): Response {
$result = Webhook::verifySignature(
$request->getContent(),
$request->header("X-Postscale-Signature"),
config("services.postscale.webhook_secret"),
);
if (!$result->valid()) {
return response("invalid", 401);
}
return response("", 204);
});
Symfony with the PHP SDK:
use Postscale\Webhook;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
final class PostscaleWebhookController
{
#[Route("/postscale/webhook", methods: ["POST"])]
public function __invoke(Request $request): Response
{
$result = Webhook::verifySignature(
$request->getContent(),
$request->headers->get("X-Postscale-Signature"),
$_ENV["POSTSCALE_WEBHOOK_SECRET"],
);
if (!$result->valid()) {
return new Response("invalid", 401);
}
return new Response("", 204);
}
}
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.delivered, email.bounced, or
email.deferred after Postscale processes the downstream delivery result.
To exercise the inbound path, send mail to a domain you've configured for
inbound processing.