# Postify webhooks — event catalog & verification

Subscribe HTTPS endpoints to workspace events at [Settings → Webhooks](https://app.usepostify.com/settings/webhooks) or via [POST /v1/webhook-endpoints](https://usepostify.com/docs/api-reference/webhooks/createWebhookEndpoint.md). Deliveries are signed per the [Standard Webhooks](https://www.standardwebhooks.com) spec.

## Envelope

Every delivery body is `{ id, type, createdAt, data }`. `id` equals the `webhook-id` header and is identical across retries — use it as your dedup key (delivery is at-least-once). `data` is event-specific.

## Event types

### post.published

A publish run finished with at least one variant delivered to a real platform. `succeeded`/`failed` count per-channel variants — a mixed run still emits post.published (check `failed` for partial delivery).

```json
{
  "id": "evt_01j9x2y3z4abcdefghijklmnop",
  "type": "post.published",
  "createdAt": "2026-07-20T14:30:00.000Z",
  "data": {
    "postId": "cmc9x2y3z0001abcd",
    "type": "single",
    "attempts": 2,
    "succeeded": 2,
    "failed": 0,
    "publishedAt": "2026-07-20T14:05:12.000Z",
    "platforms": [
      "linkedin",
      "bluesky"
    ],
    "preview": "Shipping the new scheduler today…",
    "failures": []
  }
}
```

### post.failed

A publish run finished with zero successful deliveries — every target channel failed. `publishedAt` is null.

```json
{
  "id": "evt_01j9x2y3z4abcdefghijklmnop",
  "type": "post.failed",
  "createdAt": "2026-07-20T14:30:00.000Z",
  "data": {
    "postId": "cmc9x2y3z0001abcd",
    "type": "single",
    "attempts": 2,
    "succeeded": 0,
    "failed": 2,
    "publishedAt": null,
    "platforms": [
      "linkedin",
      "bluesky"
    ],
    "preview": "Shipping the new scheduler today…",
    "failures": [
      {
        "platform": "linkedin",
        "handle": "Acme Inc",
        "error": "401 Unauthorized — token expired",
        "httpCode": 401
      }
    ]
  }
}
```

### channel.connected

A social account finished its OAuth (or app-password) connect flow and is ready to receive posts.

```json
{
  "id": "evt_01j9x2y3z4abcdefghijklmnop",
  "type": "channel.connected",
  "createdAt": "2026-07-20T14:30:00.000Z",
  "data": {
    "channelId": "cmc9aa1bb0002efgh",
    "platform": "linkedin",
    "handle": "Acme Inc"
  }
}
```

### channel.reauth_required

A channel's platform token could not be refreshed and the user must reconnect the account. Posts targeting this channel will fail until it is re-authorized.

```json
{
  "id": "evt_01j9x2y3z4abcdefghijklmnop",
  "type": "channel.reauth_required",
  "createdAt": "2026-07-20T14:30:00.000Z",
  "data": {
    "channelId": "cmc9aa1bb0002efgh",
    "platform": "x",
    "handle": "@acme"
  }
}
```

### delivery.failed

A manual per-channel delivery retry failed again. `error` carries a platform-response excerpt for debugging.

```json
{
  "id": "evt_01j9x2y3z4abcdefghijklmnop",
  "type": "delivery.failed",
  "createdAt": "2026-07-20T14:30:00.000Z",
  "data": {
    "deliveryAttemptId": "cmc9cc2dd0003ijkl",
    "postId": "cmc9x2y3z0001abcd",
    "attemptCount": 3,
    "error": "429 Too Many Requests from platform API"
  }
}
```

## Verifying signatures

Headers: `webhook-id`, `webhook-timestamp` (unix seconds), `webhook-signature` (`v1,<base64>`; multiple space-separated during secret rotation). The signature is HMAC-SHA256 over `${id}.${timestamp}.${rawBody}` with your base64-decoded `whsec_` secret. Verify over the RAW request bytes, enforce ±5 minutes timestamp tolerance, and accept any one matching signature. `@postify/sdk` ships a verifier (open source, arriving on npm soon).

```js
import { createHmac, timingSafeEqual } from "node:crypto";

// IMPORTANT: verify over the RAW request bytes — parsing and re-serializing
// the JSON changes the bytes and breaks the signature.
export function verifyWebhook(
  rawBody: string,
  headers: Record<string, string>,
  secret: string, // "whsec_…" from endpoint creation (shown once)
): boolean {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"]; // "v1,<base64> v1,<base64>…"
  if (!id || !timestamp || !signatures) return false;

  // Reject stale/future timestamps (±5 minutes) to block replays.
  const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(skew) || skew > 300) return false;

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  // Multiple space-separated signatures appear during secret rotation —
  // accept if ANY matches.
  return signatures.split(" ").some((candidate) => {
    const [version, sig] = candidate.split(",");
    if (version !== "v1" || !sig) return false;
    const given = Buffer.from(sig, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

## Retries & failure ladder

Failed deliveries retry on this schedule after the initial attempt: 30s, 2m, 10m, 30m, 2h, 5h, 10h, 14h — 9 attempts over ~31.7 hours. Warning emails go out at 5/10/15 consecutive failures; the endpoint auto-disables at 20. Any 2xx resets the counter; re-enable an auto-disabled endpoint via PATCH `enabled: true` or in settings.

Delivery is at-least-once: dedup on `webhook-id`. Endpoint URLs must be public HTTPS on port 443 — loopback, private, link-local, and cloud-metadata addresses are rejected at creation and at every delivery.
