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-timestampto 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:
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_atis set. - Any 2xx resets the counter to 0. Re-enable an auto-disabled endpoint via
PATCHenabled: trueor 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.
