Postbox docs

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

EventFires when
message.receivedAn inbound message arrived and was stored in a mailbox.
message.sentAn outbound message was accepted for delivery, not yet confirmed delivered.
message.deferredThe receiving server asked us to try again later. Delivery is still in progress.
message.deliveredThe receiving server accepted an outbound message.
message.bouncedAn outbound message bounced. The payload carries the bounce type.
message.complainedA recipient marked an outbound message as spam.
message.failedAn outbound message was rejected outright and will not be retried.
domain.dns.verifiedA 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:

HeaderValue
X-Postbox-EventThe event type.
X-Postbox-TimestampUnix seconds when the signature was produced.
X-Postbox-SignatureHex HMAC-SHA256 of `${timestamp}.${rawBody}`, keyed with the endpoint's secret.

Two rules decide whether your verification is real or decorative:

  1. Sign over the raw body, byte for byte. A re-serialized object will not match, because key order and whitespace change the bytes.
  2. 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:

AttemptRoughly when
1Immediately
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.