Webhooks
Subscribe to events, verify every delivery, and understand the retry and auto-disable rules.
Postbox fans events out to your endpoints, signed. Webhooks are the durable path: they retry. For a live UI feed, use Events instead, or both.
Subscribe
import { Postbox } from "@postbox/sdk";
const pb = new Postbox({
apiKey: process.env.PB_KEY!,
projectId: process.env.PROJECT_ID!,
});
const webhook = await pb.webhooks.create({
url: "https://acme.com/hooks/postbox",
events: ["message.received", "message.bounced"],
});import os
from postbox import Postbox
pb = Postbox(api_key=os.environ["PB_KEY"], project_id=os.environ["PROJECT_ID"])
webhook = pb.webhooks.create(
{
"url": "https://acme.com/hooks/postbox",
"events": ["message.received", "message.bounced"],
}
)using Postbox;
var pb = new PostboxClient(
new PostboxOptions
{
ApiKey = Environment.GetEnvironmentVariable("PB_KEY")!,
ProjectId = Environment.GetEnvironmentVariable("PROJECT_ID")!,
}
);
var webhook = await pb.Webhooks.CreateAsync(
new WebhooksCreateBody
{
Url = "https://acme.com/hooks/postbox",
Events = new List<string> { "message.received", "message.bounced" },
}
);import cloud.postboxapp.*;
import cloud.postboxapp.gen.*;
import java.util.*;
Postbox pb = new Postbox(PostboxOptions.builder().apiKey(System.getenv("PB_KEY")).projectId(System.getenv("PROJECT_ID")).build());
var webhook = pb.webhooks.create(
WebhooksCreateBody.builder()
.url("https://acme.com/hooks/postbox")
.events(List.of("message.received", "message.bounced"))
.build());import postbox "github.com/postboxapp/postbox-go"
pb, err := postbox.New(postbox.Options{APIKey: os.Getenv("PB_KEY"), ProjectID: os.Getenv("PROJECT_ID")})
ctx := context.Background()
webhook, err := pb.Webhooks.Create(ctx, &postbox.WebhooksCreateBody{
Url: "https://acme.com/hooks/postbox",
Events: []string{"message.received", "message.bounced"},
})curl -X POST https://api.postboxapp.cloud/v1/webhooks \
-H "Authorization: Bearer $PB_KEY" \
-H "X-Project-Id: $PROJECT_ID" \
-H "Content-Type: application/json" \
-d '{
"url": "https://acme.com/hooks/postbox",
"events": ["message.received", "message.bounced"]
}'The response carries a generated signing secret (whsec_…) if you did not supply
one. Store it now. You need it to verify deliveries.
events matches on exact event name. There is no wildcard. Omit it and the
endpoint receives message.received only.
Event types
| Event | Fires when |
|---|---|
message.received | An inbound message arrived and was stored in a mailbox. |
message.sent | An outbound message was accepted for delivery, not yet confirmed delivered. |
message.deferred | The receiving server asked us to try again later. Delivery is still in progress. |
message.delivered | The receiving server accepted an outbound message. |
message.bounced | An outbound message bounced. The payload carries the bounce type. |
message.complained | A recipient marked an outbound message as spam. |
message.failed | An outbound message was rejected outright and will not be retried. |
domain.dns.verified | A domain passed DNS verification and is ready to send and receive. |
That is the whole list. Any event you can receive is on it, and an endpoint subscribed to a name that is not on it will never fire.
message.bounced carries bounceType (hard or soft) alongside the
recipients, so you can suppress a hard bounce and retry a soft one.
message.deferred is not a failure: the receiving server is busy or greylisting,
and the message is still queued. Expect a later message.delivered or
message.bounced for the same send.
See Sending for how the outbound events relate in time.
Verify every delivery
Each delivery carries three headers:
| Header | Value |
|---|---|
X-Postbox-Event | The event type. |
X-Postbox-Timestamp | Unix seconds when the signature was produced. |
X-Postbox-Signature | Hex HMAC-SHA256 of `${timestamp}.${rawBody}`, keyed with the endpoint's secret. |
Two rules decide whether your verification is real or decorative:
- Sign over the raw body, byte for byte. A re-serialized object will not match, because key order and whitespace change the bytes.
- Compare in constant time, and reject anything outside the tolerance window (5 minutes by default) so a captured delivery cannot be replayed later.
import express from "express";
import { verifyWebhook } from "@postbox/sdk";
app.post(
"/hooks/postbox",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const event = verifyWebhook(
req.body, // the raw Buffer, not a parsed object
req.header("X-Postbox-Signature"),
req.header("X-Postbox-Timestamp"),
process.env.POSTBOX_WEBHOOK_SECRET!,
);
// handle event.type ...
res.sendStatus(204);
} catch {
res.status(400).send("invalid signature");
}
},
);import crypto from "node:crypto";
function verify(
rawBody: string,
signature: string,
timestamp: string,
secret: string,
) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}express.json() gives you a parsed object and throws the original bytes away,
so signatures will never match. Mount express.raw() on the webhook route
specifically, before any global JSON body parser.
Responding
Return any 2xx to acknowledge. Anything else counts as a failure and is
retried.
Acknowledge first, then do the work. If your handler takes longer than the delivery timeout, the delivery is recorded as failed and retried even though your code ran, so slow handlers turn into duplicate processing. Enqueue and return.
Retries
A failed delivery is retried with exponential backoff starting at 60 seconds, up to 5 attempts total:
| Attempt | Roughly when |
|---|---|
| 1 | Immediately |
| 2 | +1 minute |
| 3 | +2 minutes |
| 4 | +4 minutes |
| 5 | +8 minutes |
After the fifth, the delivery is marked FAILED and not retried again.
Inspect what happened:
const deliveries = await pb.webhooks.listDeliveries(webhookId);deliveries = pb.webhooks.list_deliveries(webhook_id)var deliveries = await pb.Webhooks.ListDeliveriesAsync(webhookId);var deliveries = pb.webhooks.listDeliveries(webhookId);deliveries, err := pb.Webhooks.ListDeliveries(ctx, webhookID)curl https://api.postboxapp.cloud/v1/webhooks/$WEBHOOK_ID/deliveries \
-H "Authorization: Bearer $PB_KEY" -H "X-Project-Id: $PROJECT_ID"Auto-disable
An endpoint tracks consecutive failed deliveries and disables itself at 10. Any single successful delivery resets the counter to zero.
The counter moves once per delivery, on its final attempt, not once per attempt. Ten consecutive failures therefore means ten separate events that each exhausted all 5 attempts, roughly 15 minutes of retrying apiece. A handler that is briefly down for a deploy costs one count per event it misses, not five.
Re-enable a disabled endpoint through the API or the console.
Endpoints that are never retried
A delivery target must resolve to a public address. A URL pointing at loopback, a private range, link-local, or cloud-metadata is refused before any request is made, and because that verdict cannot change on a retry, the delivery is not retried and the endpoint is disabled immediately.
This is the one case that skips the 10-failure budget. It is a configuration error rather than an outage: fix the URL, then re-enable the endpoint. A name that merely fails to resolve is treated as an ordinary transient failure and retried normally.
Testing against localhost
A tunnel (ngrok, cloudflared) gives you a public URL that forwards to your
machine. Pointing an endpoint straight at localhost or a private IP disables
it on the first event. For local work without a tunnel, the
SSE stream needs no public URL at all.
Idempotency on your side
Retries mean your handler will see the same event more than once. Delivery is at least once, not exactly once. Key your processing off the message or event id and make the handler safe to run twice.