Webhooks
Receive real-time delivery events via webhooks
Webhooks notify your application in real time when events happen -- emails delivered, bounced, opened, clicked, and more. Instead of polling the API, you register an endpoint URL and Postkit sends HTTP POST requests with event data as they occur.
Create a webhook endpoint
Register a URL to receive events. You can subscribe to specific event types or use * to receive all events.
curl -X POST https://api.postkit.eu/v1/webhooks \
-H "Authorization: Bearer pk_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/postkit",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'const response = await fetch('https://api.postkit.eu/v1/webhooks', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_abc123...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com/webhooks/postkit',
events: ['email.delivered', 'email.bounced', 'email.complained'],
}),
});
const webhook = await response.json();import requests
response = requests.post(
'https://api.postkit.eu/v1/webhooks',
headers={'Authorization': 'Bearer pk_live_abc123...'},
json={
'url': 'https://example.com/webhooks/postkit',
'events': ['email.delivered', 'email.bounced', 'email.complained'],
},
)
webhook = response.json()The response includes a signing_secret starting with whsec_. Save it -- you'll need it to verify webhook signatures.
The signing secret is only returned when you create the webhook or rotate the secret. Store it securely alongside your API key.
Event types
Postkit sends webhooks for the following event types:
| Event | Description |
|---|---|
email.sent | Email accepted and sent to the mail server |
email.delivered | Email successfully delivered to the recipient's inbox |
email.delivery_delayed | Delivery is being retried (temporary failure) |
email.bounced | Email permanently or temporarily bounced |
email.complained | Recipient marked email as spam |
email.opened | Recipient opened the email (tracking pixel) |
email.clicked | Recipient clicked a link in the email |
email.canceled | Scheduled email was canceled before sending |
email.received | Inbound email received on your domain |
contact.created | New contact was created |
contact.updated | Contact details were updated |
contact.deleted | Contact was deleted |
contact.unsubscribed | Contact unsubscribed from a topic |
domain.verified | Domain DNS records verified successfully |
domain.dns_changed | Domain DNS records changed (re-verification needed) |
Use wildcard patterns like email.* to subscribe to all email events, or * for everything.
Payload format
Every webhook delivery is an HTTP POST with a JSON body following this envelope structure:
{
"type": "email.delivered",
"id": "whl_abc123def456",
"created_at": "2026-03-31T12:00:00Z",
"data": {
"email_id": "em_a1b2c3d4e5",
"from": "Acme <noreply@acme.eu>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"tags": ["welcome"],
"timestamp": "2026-03-31T12:00:00Z"
}
}type-- the event type (matches your subscription)id-- unique delivery ID (prefixedwhl_)created_at-- when the event occurred (ISO 8601)data-- event-specific payload (varies by event type)
Verify webhook signatures
Postkit signs every webhook using the Standard Webhooks specification. Always verify signatures before processing webhook data to ensure the request came from Postkit.
How signing works
Each webhook request includes three headers:
| Header | Example | Purpose |
|---|---|---|
webhook-id | whl_abc123def456 | Unique delivery identifier |
webhook-timestamp | 1711900000 | Unix timestamp (seconds) |
webhook-signature | v1,K7rMz... | HMAC-SHA256 signature |
The signature is computed over the string {webhook-id}.{webhook-timestamp}.{body} using your signing secret as the HMAC key.
Verification steps
- Extract the
webhook-id,webhook-timestamp, andwebhook-signatureheaders - Reconstruct the signed content:
"{webhook-id}.{webhook-timestamp}.{raw JSON body}" - Strip the
whsec_prefix from your signing secret and base64-decode it to get the key bytes - Compute HMAC-SHA256 using the decoded key bytes and the signed content
- Base64-encode the result and prepend
v1, - Compare your computed signature with the
webhook-signatureheader - Optionally reject requests where
webhook-timestampis older than 5 minutes
Always base64-decode the secret after stripping the whsec_ prefix. Using the secret string directly as HMAC key bytes will produce incorrect signatures.
Verification code examples
# Verify a webhook signature with openssl
WEBHOOK_ID="whl_abc123def456"
WEBHOOK_TIMESTAMP="1711900000"
WEBHOOK_SECRET="whsec_dGVzdHNlY3JldGtleXRlc3Rz"
# 1. Reconstruct signed content
SIGNED_CONTENT="${WEBHOOK_ID}.${WEBHOOK_TIMESTAMP}.$(cat /tmp/body.json)"
# 2. Decode secret (strip whsec_ prefix, base64 decode)
SECRET_BYTES=$(echo -n "${WEBHOOK_SECRET#whsec_}" | base64 -d)
# 3. Compute HMAC-SHA256 and base64 encode
EXPECTED="v1,$(echo -n "$SIGNED_CONTENT" | openssl dgst -sha256 -hmac "$SECRET_BYTES" -binary | base64)"
echo "Expected: $EXPECTED"
# Compare with webhook-signature headerimport { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(
body: string,
headers: Record<string, string>,
secret: string
): boolean {
const msgId = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
// Reject old timestamps (5 min tolerance)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Timestamp too old');
}
// Decode the signing secret
const secretBytes = Buffer.from(
secret.replace('whsec_', ''),
'base64'
);
// Reconstruct signed content
const signedContent = `${msgId}.${timestamp}.${body}`;
// Compute expected signature
const hmac = createHmac('sha256', secretBytes);
hmac.update(signedContent);
const expected = `v1,${hmac.digest('base64')}`;
// Check against all provided signatures (key rotation support)
return signatures.split(' ').some((sig) => {
try {
return timingSafeEqual(
Buffer.from(expected),
Buffer.from(sig)
);
} catch {
return false;
}
});
}import base64
import hashlib
import hmac
import time
def verify_webhook(body: str, headers: dict, secret: str) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signatures = headers["webhook-signature"]
# Reject old timestamps (5 min tolerance)
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Timestamp too old")
# Decode the signing secret
secret_bytes = base64.b64decode(secret.removeprefix("whsec_"))
# Reconstruct signed content
signed_content = f"{msg_id}.{timestamp}.{body}"
# Compute expected signature
mac = hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256)
expected = f"v1,{base64.b64encode(mac.digest()).decode()}"
# Check against all provided signatures (key rotation support)
return any(
hmac.compare_digest(expected, sig)
for sig in signatures.split(" ")
)Retry behavior
If your endpoint returns a non-2xx status code or doesn't respond within 30 seconds, Postkit retries the delivery with exponential backoff.
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 (initial) | Immediate | 0 |
| 2 | ~5 seconds | ~5s |
| 3 | ~5 minutes | ~5min |
| 4 | ~30 minutes | ~35min |
| 5 | ~2 hours | ~2.5h |
| 6 | ~5 hours | ~7.5h |
| 7 | ~10 hours | ~17.5h |
| 8 | ~10 hours | ~27.5h |
Each delay includes +/- 20% jitter to prevent thundering herds. After 8 failed attempts (~28 hours), the delivery is marked as exhausted.
Best practices
- Return 200 quickly. Process webhook data asynchronously (e.g., queue it) and return a 200 immediately. Postkit waits up to 30 seconds before timing out.
- Handle duplicates. Use the
idfield (e.g.,whl_abc123def456) as an idempotency key. The same event may be delivered more than once during retries. - Verify signatures. Always verify the
webhook-signatureheader before trusting the payload. This prevents spoofed requests. - Monitor failures. If your endpoint fails repeatedly, Postkit tracks consecutive failures. After too many failures, the webhook may be automatically disabled.
What's next?
- Domain Setup -- configure DNS records for production sending
- API Reference -- explore all webhook endpoints and schemas
- Quickstart -- send your first email