Postilio docs

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.

CallWhat it does
GET /v1/webhooksyour endpoints
POST /v1/webhooksadd 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}/secretrotate the secret; the old one keeps signing for 24 hours
POST /v1/webhooks/{id}/testsend a webhook.test.v1 event, once (no retries), also to a paused endpoint
GET /v1/webhooks/{id}/deliveriesthe delivery log: attempts, status codes, errors (status, before, limit)
POST /v1/webhooks/{id}/deliveries/{deliveryId}/retryone 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:

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

EventWhen
acceptedthe API or SMTP took the message
queuedhanded to the mail server
deliveredthe receiving server accepted it
deferreda temporary failure; it is retried
bouncedrefused for good (also a bounce that arrives later)
expirednot delivered within a day
complainedthe recipient marked it as spam
suppressednot 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

Verifying the signature

Every request has three headers:

HeaderContent
webhook-idthe event id: drop repeats by it
webhook-timestampwhen it was sent, in Unix seconds
webhook-signaturev1, 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();
});