> ## Documentation Index
> Fetch the complete documentation index at: https://docs.postbreeze.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time events when posts publish, accounts disconnect, comments arrive, and more.

Webhooks let you react to events in Postbreeze as they happen instead
of polling. When a post publishes, an account disconnects, or a
comment lands in the inbox, Postbreeze fires a signed HTTPS request
to a URL you control.

## How it works

1. **Register a webhook** in the dashboard at **Settings →
   Developers → Webhooks** (or via the dashboard's `me/webhooks`
   endpoints). Pick the events you want, paste your HTTPS URL, and
   Postbreeze generates a signing secret.
2. **Postbreeze signs every delivery** with an HMAC-SHA256 of the
   request body. Verify the signature in your handler before doing
   anything with the payload.
3. **You respond `2xx` within 10 seconds**. Anything else is a
   failure and triggers the retry schedule below.

<Note>
  Webhooks are **outbound only** — Postbreeze pushes events to your
  endpoint. You don't poll for them, and you don't need an API key on
  your endpoint.
</Note>

## Quick start

```ts handler.ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.POSTBREEZE_WEBHOOK_SECRET!;

export async function POST(req: Request): Promise<Response> {
  const raw = await req.text();
  const sig = req.headers.get("x-postbreeze-signature");

  if (!sig || !verify(raw, sig, SECRET)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(raw) as {
    event: string;
    deliveryId: string;
    sentAt: string;
    payload: Record<string, unknown>;
  };

  // Acknowledge quickly. Move heavy work to a queue.
  void enqueueBackgroundJob(event);
  return new Response("ok", { status: 200 });
}

/** Verifies a `t=<unix>,v1=<hex>` header against the raw body. */
function verify(body: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string]),
  );
  const ts = parts.t;
  const sig = parts.v1;
  if (!ts || !sig) return false;

  // Reject deliveries older than 5 minutes — defends against replay
  // attacks once an attacker has a leaked body + signature.
  const ageSec = Math.floor(Date.now() / 1000) - Number(ts);
  if (!Number.isFinite(ageSec) || Math.abs(ageSec) > 5 * 60) return false;

  const expected = createHmac("sha256", secret)
    .update(`${ts}.${body}`)
    .digest("hex");
  const a = Buffer.from(sig, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

## Request format

Every delivery is a `POST` with `Content-Type: application/json`. The
body is the same shape for every event — the per-event detail lives
on `payload`.

```json theme={null}
{
  "apiVersion": "2026-05-01",
  "event": "post.published",
  "deliveryId": "whd_clw9q6f1m000008jp7j8h7q3a",
  "sentAt": "2026-06-15T09:00:32.184Z",
  "payload": {
    "postId": "pst_clw9q6f1m000008jp7j8h7q3a",
    "workspaceId": "wsp_clw9q6f1m000008jp7j8h7q3a",
    "publishedAt": "2026-06-15T09:00:31.000Z",
    "targetCount": 3
  }
}
```

### Headers Postbreeze sets

| Header                     | Value                                              |
| -------------------------- | -------------------------------------------------- |
| `Content-Type`             | `application/json`                                 |
| `User-Agent`               | `Postbreeze-Webhook/1.0`                           |
| `X-Postbreeze-Event`       | Event key, e.g. `post.published`                   |
| `X-Postbreeze-Delivery-Id` | Stable per-delivery id. Use this for deduplication |
| `X-Postbreeze-Api-Version` | `2026-05-01` (matches `apiVersion` in the body)    |
| `X-Postbreeze-Signature`   | `t=<unix-seconds>,v1=<hex-hmac>`                   |

You can also configure **custom headers** on each webhook (e.g.
`X-Internal-Token: …`) — Postbreeze merges them in but cannot
override the default headers above.

## Signature verification

The `X-Postbreeze-Signature` header has the form:

```
t=1718446832,v1=8a93f4e2b1d57a8c0e3f6b912d4a5c8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c
```

* `t` is the Unix timestamp in seconds when Postbreeze signed the
  request.
* `v1` is the HMAC-SHA256 of `<t>.<rawBody>` using your webhook's
  signing secret, hex-encoded.

To verify:

1. Split the header by `,` and read `t` + `v1`.
2. Compute `HMAC-SHA256("<t>.<rawBody>", secret)`.
3. Compare in constant time.
4. Reject if the timestamp is more than 5 minutes off the current
   time (replay protection).

<Warning>
  **Verify against the raw request body**, not a re-serialized JSON
  object. Any whitespace difference or key reordering changes the hash.
  Read the body as a string before parsing it.
</Warning>

### Rotating the secret

Rotate the signing secret from **Settings → Developers → Webhooks →
Rotate secret**. During the rotation window (24 hours by default),
Postbreeze signs every delivery with **both** secrets:

```
X-Postbreeze-Signature: t=1718446832,v1=<new>,v1=<old>
```

Accept either `v1` value. After the window closes the old secret
stops being attached.

## Idempotency & deduplication

Webhooks are delivered **at least once**. A receiver that times out
on attempt 1 and succeeds on attempt 2 receives the same event
twice.

Use `X-Postbreeze-Delivery-Id` as your deduplication key — it's
stable across retries of the same delivery. The cheapest pattern is
a uniquely-indexed table:

```sql theme={null}
CREATE TABLE webhook_inbox (
  delivery_id TEXT PRIMARY KEY,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

Insert on receive; if the insert fails on the unique constraint, you
have already processed this delivery — `ack` and skip.

## Retry policy

A delivery is **successful** when your endpoint returns a `2xx`
response within 10 seconds. Anything else fails the attempt.

| Attempt | Delay before next attempt |
| ------- | ------------------------- |
| 1       | immediate                 |
| 2       | 30 seconds                |
| 3       | 5 minutes                 |
| 4       | 1 hour                    |
| 5       | 6 hours                   |
| 6       | 24 hours                  |

After **6 attempts** the delivery is dropped to the failed-deliveries
list (visible in the dashboard) and does not retry further.

### Which failures retry?

| Outcome                    | Behavior                                                                                                                       |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `408`, `429`, `5xx`        | Retried per the schedule above                                                                                                 |
| Network error / timeout    | Retried                                                                                                                        |
| `401`, `403`, `404`, `410` | **Not retried** (terminal — your endpoint is rejecting or gone)                                                                |
| Any other `4xx`            | **Not retried** (your handler has a bug we can't fix by waiting)                                                               |
| `3xx` redirect             | **Not retried** — Postbreeze refuses to follow redirects to defend against SSRF. Point your webhook URL at the final endpoint. |

### Auto-disable

After **15 consecutive terminal failures** Postbreeze automatically
disables the webhook and fires a final `webhook.disabled_by_system`
event to the same endpoint (best-effort). Re-enable it from the
dashboard once the endpoint is back.

## Event catalogue

All event keys, in canonical order. Schemas use TypeScript-style
notation — `string | null` means nullable.

### Posts

| Event                     | When it fires                                |
| ------------------------- | -------------------------------------------- |
| `post.scheduled`          | A post is scheduled to publish in the future |
| `post.published`          | All targets of a post published successfully |
| `post.failed`             | All targets of a post failed                 |
| `post.partial`            | Some targets succeeded, others failed        |
| `post.cancelled`          | A scheduled post was canceled before publish |
| `post.platform.published` | One target of a multi-platform post landed   |
| `post.platform.failed`    | One target of a multi-platform post failed   |

```ts post.published payload theme={null}
{
  postId: string;
  workspaceId: string;
  publishedAt: string;     // ISO-8601
  targetCount: number;
}
```

```ts post.partial payload theme={null}
{
  postId: string;
  workspaceId: string;
  successCount: number;
  failureCount: number;
}
```

```ts post.platform.published payload theme={null}
{
  postId: string;
  workspaceId: string;
  targetId: string;
  platform: string;        // "INSTAGRAM" | "X" | "TIKTOK_PERSONAL" | …
  externalPostId: string | null;
  externalUrl: string | null;
  publishedAt: string;
}
```

```ts post.platform.failed payload theme={null}
{
  postId: string;
  workspaceId: string;
  targetId: string;
  platform: string;
  errorCode: string | null;
  errorMessage: string | null;
}
```

### Accounts

| Event                   | When it fires                                                                       |
| ----------------------- | ----------------------------------------------------------------------------------- |
| `account.connected`     | A social account is connected to a workspace                                        |
| `account.disconnected`  | A connected account is disconnected (user-initiated or revoked externally)          |
| `account.token_expired` | The platform refresh token expired and the account is now read-only until reconnect |

```ts account.connected payload theme={null}
{
  accountId: string;
  workspaceId: string;
  platform: string;
  handle: string;
  displayName: string | null;
  reconnected: boolean;    // true when a previously-disconnected row was revived
}
```

```ts account.disconnected payload theme={null}
{
  accountId: string;
  workspaceId: string;
  platform: string;
  handle: string;
  reason: "user_initiated" | "external_revocation";
}
```

### Comments

| Event              | When it fires                                                        |
| ------------------ | -------------------------------------------------------------------- |
| `comment.received` | A new inbox comment is received (Instagram / X / YouTube / Facebook) |

```ts comment.received payload theme={null}
{
  commentId: string;
  workspaceId: string;
  socialAccountId: string;
  platform: string;
  externalPostId: string | null;
  authorHandle: string | null;
  body: string;
  createdAt: string;
}
```

### Webhook lifecycle

These are meta-events about the webhook itself — useful for
detecting your endpoint being auto-disabled.

| Event                        | When it fires                                                         |
| ---------------------------- | --------------------------------------------------------------------- |
| `webhook.test`               | Triggered by the "Send test event" button in the dashboard            |
| `webhook.disabled_by_system` | Fired once when consecutive failures cross the auto-disable threshold |

```ts webhook.disabled_by_system payload theme={null}
{
  webhookId: string;
  reason: "consecutive_failure_threshold_reached";
  failureStreak: number;
}
```

## Best practices

* **Verify the signature on every delivery.** Don't skip in development.
* **Deduplicate by `deliveryId`.** Treat at-least-once as a guarantee, not a wish.
* **Acknowledge quickly.** Return `2xx` within 10 seconds; queue heavy work for a background worker.
* **Treat events as notifications, not state.** If you missed a delivery (auto-disable, your endpoint was down), re-fetch from the API to reconcile — never trust the webhook to be your only source of truth.
* **Lock the receiver down.** Reject requests that aren't a `POST` with `Content-Type: application/json`. Use the signing secret as your only authority — don't IP-allowlist Postbreeze.
* **Handle the `disabled_by_system` event explicitly.** Page on it; otherwise you'll only notice events have stopped flowing when something downstream breaks.
* **Use the dashboard's "Deliveries" tab** to inspect recent failures — every attempt logs its HTTP status, response body (truncated), and the request headers we sent.

## Limits

* **Max 50 webhooks per user**
* **Max payload size persisted**: 64 KB (large payloads still deliver, but the response/request bodies stored for the deliveries log are truncated)
* **Custom headers**: up to 10 per webhook, max 1 KB total
* **URL must be HTTPS** — `http://` is rejected at create time
* **URL must resolve to a public IP** — RFC1918, link-local, and IMDS hostnames are rejected at both create time and delivery time (DNS rebinding protection)

## Managing webhooks

Webhooks are managed from the dashboard at **Settings → Developers →
Webhooks** — create, rotate secrets, disable, send a test event,
and browse delivery history. The management endpoints are
cookie-only (`/me/webhooks`); API keys can't mint or revoke webhooks.
