Webhooks

Postify pushes workspace events to your HTTPS endpoints, signed per the Standard Webhooks spec. Manage endpoints in Settings → Webhooks or via the /v1/webhook-endpoints API.

The envelope

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

Event catalog

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).

{
  "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"
  }
}

post.failed

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

{
  "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
  }
}

channel.connected

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

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

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

{
  "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"
  }
}

The test-delivery endpoints send a synthetic webhook.test event, signed exactly like a real one — it is never subscribable.

Verifying signatures

Each delivery carries three headers; the signature is HMAC-SHA256 over ${id}.${timestamp}.${rawBody} with your base64-decoded whsec_ secret (shown once at endpoint creation):

webhook-id: evt_01j9x2y3z4abcdefghijklmnop
webhook-timestamp: 1784990000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pf9y1uJc0a5VbE=
  • Verify over the RAW bytes. Parsing and re-serializing the JSON changes the bytes and breaks the signature.
  • Enforce a ±5 minute tolerance on webhook-timestamp to block replays.
  • During secret rotation the header carries multiple space-separated v1,… signatures — accept if any one matches.
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);
  });
}

Any Standard Webhooks library verifies these too, and @postify/sdk ships a verifier (open source, arriving on npm soon).

Retries

A non-2xx response (or no response) schedules a retry. After the initial attempt, retries follow this backoff — 9 attempts over ~31.7 hours:

#2 +30s#3 +2m#4 +10m#5 +30m#6 +2h#7 +5h#8 +10h#9 +14h

Failure ladder & auto-disable

  • Warning emails at 5 / 10 / 15 consecutive failed deliveries.
  • Auto-disable at 20 — the endpoint stops receiving deliveries and its auto_disabled_at is set.
  • Any 2xx resets the counter to 0. Re-enable an auto-disabled endpoint via PATCH enabled: true or in settings.

Delivery guarantees

  • At-least-once: the same event can arrive more than 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.
  • Respond fast (2xx) and process async — slow endpoints count as failures.