Webhooks
Postilio posts the events of your messages to an HTTPS endpoint of yours, so you do not have to poll GET /v1/emails/{id}. Deliveries are signed with the Standard Webhooks scheme, so any Standard Webhooks library can verify them, and so can the few lines below.
Endpoints
Add an endpoint on the portal's Webhooks page, or with an API key that has the webhooks:manage scope:
curl https://api.postilio.eu/v1/webhooks \
-H "Authorization: Bearer $POSTILIO_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://api.example.com/hooks/postilio", "events": ["delivered", "bounced", "complained"] }'
The answer holds the signing secret (whsec_…). It is shown this once; Postilio keeps it encrypted and never returns it again. Rotate it to get a new one.
| Call | What it does |
|---|---|
GET /v1/webhooks | your endpoints |
POST /v1/webhooks | add one: url (HTTPS), events, optional description, mode (live or test) |
GET /v1/webhooks/{id} | read one |
PATCH /v1/webhooks/{id} | change url, events, description or paused; what you leave out stays as it is |
DELETE /v1/webhooks/{id} | remove it |
POST /v1/webhooks/{id}/secret | rotate the secret; the old one keeps signing for 24 hours |
POST /v1/webhooks/{id}/test | send a webhook.test.v1 event, once (no retries), also to a paused endpoint |
GET /v1/webhooks/{id}/deliveries | the delivery log: attempts, status codes, errors (status, before, limit) |
POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry | one more attempt right away, same id and payload (resume a paused endpoint first) |
A project has at most 10 endpoints. A test endpoint only gets the events of messages sent with a pk_test_ key; a live endpoint never does. Test keys cannot manage webhooks.
Which URLs are allowed
Postilio connects only to public addresses, so a webhook cannot be used to reach anything on Postilio's own network:
https://only, at most 2048 characters, without credentials (user:pass@) or a#fragment. Authenticate deliveries by their signature; a token in the query string is fine.- No private, loopback, link-local (cloud metadata), shared, documentation, multicast or reserved addresses, for IPv4 and IPv6 alike. A host name without a dot, or ending in
.localhostor.internal, is refused when you add it. - The check runs again on every delivery, against the addresses the host name resolves to at that moment, and the connection goes to the address that was checked. A name that later points somewhere private gets its deliveries refused, and they count as failed.
Try it: the test event
Send a test event to see that your endpoint is reachable and verifies the signature:
curl -X POST https://api.postilio.eu/v1/webhooks/$WEBHOOK_ID/test \
-H "Authorization: Bearer $POSTILIO_KEY"
It answers 202 with the delivery, which is tried once right away, also when the endpoint is paused. Look at the result in the delivery log, with the status code your endpoint answered and the start of its body:
curl "https://api.postilio.eu/v1/webhooks/$WEBHOOK_ID/deliveries?limit=5" \
-H "Authorization: Bearer $POSTILIO_KEY"
Events and payload
| Event | When |
|---|---|
accepted | the API or SMTP took the message |
queued | handed to the mail server |
delivered | the receiving server accepted it |
deferred | a temporary failure; it is retried |
bounced | refused for good (also a bounce that arrives later) |
expired | not delivered within a day |
complained | the recipient marked it as spam |
suppressed | not sent: the address is on the suppression list |
The body is JSON. type carries the version: a change that is not backwards compatible gets a new type (email.delivered.v2) instead of changing this one. Fields without a value are left out, and new fields may be added. There is never message content in it: no subject, no body.
{
"type": "email.bounced.v1",
"timestamp": "2026-10-03T14:07:45.102+00:00",
"data": {
"emailId": "0199a7c4-5a1e-7d2b-9c41-6f3e0b8a2d17",
"projectId": "0199a1b2-0000-7000-8000-000000000001",
"to": "ada.lovelace@example.com",
"tag": "sign-in",
"test": false,
"event": "bounced",
"occurredAt": "2026-10-03T14:07:45.102+00:00",
"attempt": 1,
"smtpCode": 550,
"enhancedCode": "5.1.1",
"classification": "InvalidRecipient",
"response": "550 5.1.1 The email account that you tried to reach does not exist",
"remoteHost": "mx.example.com"
}
}
webhook.test.v1 has data.endpointId, data.projectId and data.test only.
Delivery
- At least once, in no particular order. The same event can arrive twice (a retry after an answer that got lost), and a
deliveredcan arrive before itsqueued. Every event has onewebhook-id, the same in every attempt, retry and replay: store the ids you handled and drop repeats. Order events bydata.occurredAtif you need to. - Success is any 2xx answer within 10 seconds. Answer first, then do the work. Redirects are not followed and count as a failure; register the final URL. Postilio reads at most the first kilobyte of your answer.
- One request at a time per endpoint. Endpoints are served in turn, a few seconds each, so a slow endpoint mostly slows its own deliveries; many slow endpoints at once delay the others too.
- Retries after a failure: 30 seconds, then doubling each time (1, 2, 4 … minutes) up to 6 hours between attempts, with some random spread, for 3 days after the event. That is about 20 attempts; a delivery that has not succeeded by then is
failedafter its next attempt. After a failure the endpoint also rests for 30 seconds before its next delivery. - Paused after 3 days of failures. When every attempt to an endpoint failed for 3 days, Postilio pauses it and mails the organization's owners. A paused endpoint gets no new events (they stay in the message log); resume it on the dashboard or with
PATCH {"paused": false}, and retry failed deliveries from the log. - IP addresses. Deliveries come from Postilio's servers in the EU. There is no fixed, published IP range yet, so do not allow them by IP address: verify the signature.
- Requests carry
User-Agent: Postilio-Webhooks/1.0,Content-Type: application/jsonand no cookies or credentials.
Verifying the signature
Every request has three headers:
| Header | Content |
|---|---|
webhook-id | the event id: drop repeats by it |
webhook-timestamp | when it was sent, in Unix seconds |
webhook-signature | v1, and the base64 of HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of the secret after whsec_ |
During the 24 hours after a rotation the header holds two signatures separated by a space, one per secret; accept the request when either matches. Refuse a timestamp more than five minutes off, so a captured request cannot be replayed later. Verify the raw body, before parsing it: re-serialized JSON is not byte for byte the same.
These examples need no packages. Postilio's own tests run them against the Standard Webhooks test vector.
Node
// Verifies a Postilio webhook (the Standard Webhooks scheme) with Node's own crypto; no packages needed.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
/**
* True when one of the signatures matches and the timestamp is less than five minutes off.
* `body` is the raw request body as a string: verify before parsing it.
*/
export function verifyWebhook(secret, { id, timestamp, signature }, body, nowSeconds = Math.floor(Date.now() / 1000)) {
const sent = Number(timestamp);
if (!Number.isInteger(sent) || Math.abs(nowSeconds - sent) > TOLERANCE_SECONDS) {
return false;
}
const key = Buffer.from(secret.startsWith('whsec_') ? secret.slice(6) : secret, 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();
// During a secret rotation the header holds two signatures, space-separated.
return signature.split(' ').some((part) => {
const [version, value] = part.split(',', 2);
const given = Buffer.from(value ?? '', 'base64');
return version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Usage: node verify.mjs <secret> <webhook-id> <webhook-timestamp> <webhook-signature> <body> [now]
if (import.meta.url === `file://${process.argv[1]}`) {
const [secret, id, timestamp, signature, body, now] = process.argv.slice(2);
const ok = verifyWebhook(secret, { id, timestamp, signature }, body, now ? Number(now) : undefined);
console.log(ok ? 'valid' : 'invalid');
process.exit(ok ? 0 : 1);
}
Python
"""Verifies a Postilio webhook (the Standard Webhooks scheme) with the standard library; no packages needed."""
import base64
import hashlib
import hmac
import sys
import time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret: str, msg_id: str, timestamp: str, signature: str, body: str, now: int | None = None) -> bool:
"""True when one of the signatures matches and the timestamp is less than five minutes off.
`body` is the raw request body: verify before parsing it.
"""
now = int(time.time()) if now is None else now
if not timestamp.isdigit() or abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = hmac.new(key, f"{msg_id}.{timestamp}.{body}".encode(), hashlib.sha256).digest()
# During a secret rotation the header holds two signatures, space-separated.
for part in signature.split():
version, _, value = part.partition(",")
try:
given = base64.b64decode(value, validate=True)
except ValueError:
continue
if version == "v1" and hmac.compare_digest(given, expected):
return True
return False
# Usage: python3 verify.py <secret> <webhook-id> <webhook-timestamp> <webhook-signature> <body> [now]
if __name__ == "__main__":
secret, msg_id, timestamp, signature, body, *rest = sys.argv[1:]
ok = verify_webhook(secret, msg_id, timestamp, signature, body, int(rest[0]) if rest else None)
print("valid" if ok else "invalid")
sys.exit(0 if ok else 1)
C#
using System.Security.Cryptography;
using System.Text;
namespace Postilio.Webhooks;
/// <summary>
/// Verifies a Postilio webhook (the Standard Webhooks scheme). Copy it into your project; it needs no packages.
/// </summary>
public static class WebhookVerifier
{
private static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);
/// <summary>
/// True when one of the signatures in <paramref name="signatureHeader"/> matches and the timestamp is less than
/// five minutes off, which turns away a replayed request. Pass the raw body, not a re-serialized one.
/// </summary>
public static bool Verify(string secret, string id, string timestamp, string signatureHeader, string body, DateTimeOffset now)
{
if (!long.TryParse(timestamp, out var seconds) || (now - DateTimeOffset.FromUnixTimeSeconds(seconds)).Duration() > Tolerance)
{
return false;
}
var key = Convert.FromBase64String(secret.StartsWith("whsec_", StringComparison.Ordinal) ? secret[6..] : secret);
var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{body}"));
// During a secret rotation the header holds two signatures, space-separated: "v1,<a> v1,<b>".
foreach (var signature in signatureHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
var parts = signature.Split(',', 2);
var given = new byte[expected.Length];
if (parts is ["v1", var value] && Convert.TryFromBase64String(value, given, out var length) && length == given.Length
&& CryptographicOperations.FixedTimeEquals(given, expected))
{
return true;
}
}
return false;
}
}
For example in ASP.NET Core:
app.MapPost("/hooks/postilio", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync();
var h = request.Headers;
if (!WebhookVerifier.Verify(secret, h["webhook-id"].ToString(), h["webhook-timestamp"].ToString(), h["webhook-signature"].ToString(), body, DateTimeOffset.UtcNow))
{
return Results.Unauthorized();
}
// Queue the work, drop it when webhook-id was seen before, and answer at once.
return Results.NoContent();
});