Skip to main content
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.
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.

Quick start

handler.ts
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.
{
  "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

HeaderValue
Content-Typeapplication/json
User-AgentPostbreeze-Webhook/1.0
X-Postbreeze-EventEvent key, e.g. post.published
X-Postbreeze-Delivery-IdStable per-delivery id. Use this for deduplication
X-Postbreeze-Api-Version2026-05-01 (matches apiVersion in the body)
X-Postbreeze-Signaturet=<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).
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.

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:
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.
AttemptDelay before next attempt
1immediate
230 seconds
35 minutes
41 hour
56 hours
624 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?

OutcomeBehavior
408, 429, 5xxRetried per the schedule above
Network error / timeoutRetried
401, 403, 404, 410Not retried (terminal — your endpoint is rejecting or gone)
Any other 4xxNot retried (your handler has a bug we can’t fix by waiting)
3xx redirectNot 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

EventWhen it fires
post.scheduledA post is scheduled to publish in the future
post.publishedAll targets of a post published successfully
post.failedAll targets of a post failed
post.partialSome targets succeeded, others failed
post.cancelledA scheduled post was canceled before publish
post.platform.publishedOne target of a multi-platform post landed
post.platform.failedOne target of a multi-platform post failed
post.published payload
{
  postId: string;
  workspaceId: string;
  publishedAt: string;     // ISO-8601
  targetCount: number;
}
post.partial payload
{
  postId: string;
  workspaceId: string;
  successCount: number;
  failureCount: number;
}
post.platform.published payload
{
  postId: string;
  workspaceId: string;
  targetId: string;
  platform: string;        // "INSTAGRAM" | "X" | "TIKTOK_PERSONAL" | …
  externalPostId: string | null;
  externalUrl: string | null;
  publishedAt: string;
}
post.platform.failed payload
{
  postId: string;
  workspaceId: string;
  targetId: string;
  platform: string;
  errorCode: string | null;
  errorMessage: string | null;
}

Accounts

EventWhen it fires
account.connectedA social account is connected to a workspace
account.disconnectedA connected account is disconnected (user-initiated or revoked externally)
account.token_expiredThe platform refresh token expired and the account is now read-only until reconnect
account.connected payload
{
  accountId: string;
  workspaceId: string;
  platform: string;
  handle: string;
  displayName: string | null;
  reconnected: boolean;    // true when a previously-disconnected row was revived
}
account.disconnected payload
{
  accountId: string;
  workspaceId: string;
  platform: string;
  handle: string;
  reason: "user_initiated" | "external_revocation";
}

Comments

EventWhen it fires
comment.receivedA new inbox comment is received (Instagram / X / YouTube / Facebook)
comment.received payload
{
  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.
EventWhen it fires
webhook.testTriggered by the “Send test event” button in the dashboard
webhook.disabled_by_systemFired once when consecutive failures cross the auto-disable threshold
webhook.disabled_by_system payload
{
  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 HTTPShttp:// 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.