Webhooks
Webhooks let PostFuze push events to your backend instead of you polling. Register one or more HTTPS endpoints, subscribe each to the events you care about, and PostFuze delivers a signed JSON payload whenever something happens — a post going live, a token expiring, an import finishing. Every delivery is signed with HMAC-SHA256 so you can verify it came from us.
Events
| Event | Fires when |
|---|---|
post.published | A post reached at least one account successfully (post status published or partial). |
post.error | A post failed to publish to every target (post status failed). |
post.target.published | A single channel (one account) of a post published — the per-account counterpart to post.published. |
post.target.failed | A single channel of a post failed permanently after retries. |
account.token_expired | An account's OAuth token could not be refreshed. Reconnect the account to resume publishing. |
account.reauth_required | An account's grant was revoked and cannot be refreshed — a full reconnect is required. |
account.connected | A social account finished the connect flow and is ready to publish. |
account.disconnected | A social account was removed or revoked. |
import.completed | A historical-post import job finished successfully. |
import.failed | An import job finished with errors or only partial results. |
The partial-success semantics matter: post.published fires as soon as one account is live, and post.error fires only when nothing went live. A post that lands on two of three accounts emits a single post.published. See Post lifecycle for how status rolls up.
Registering an endpoint
Create an endpoint with POST /webhooks. The signing_secret is returned once on creation (like an API key) — store it, you will need it to verify signatures. If you omit it, PostFuze generates one with a whsec_ prefix.
| Field | Type | Description |
|---|---|---|
url | string (URL) | HTTPS endpoint that receives deliveries. Required. |
events | string[] (min 1) | Event names to subscribe to. Required. |
description | string | Optional human-readable label. |
signing_secret | string | Optional. Provide your own secret, or let PostFuze generate a whsec_… one. |
curl https://api.postfuze.com/api/v1/webhooks \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/postfuze",
"events": ["post.published", "post.error", "account.token_expired"],
"description": "Production publishing hook"
}'{
"id": "wh_3a8c…",
"url": "https://example.com/hooks/postfuze",
"events": ["post.published", "post.error", "account.token_expired"],
"is_active": true,
"description": "Production publishing hook",
"signing_secret": "whsec_Q1d…shown_once",
"created_at": "2026-06-09T12:00:00Z",
"updated_at": "2026-06-09T12:00:00Z"
}Delivery format
PostFuze POSTs each event as JSON with these headers:
Content-Type: application/jsonUser-Agent: Postfuze-Webhooks/1.0X-Postfuze-Signature: t=<unix>,v1=<hex>— a timestamp and the HMAC-SHA256 of`${t}.${rawBody}`(present when a signing secret is set). The timestamp lets you reject replays.
Every payload shares a top-level event, an ISO-8601 millisecond UTC timestamp, and an event-specific data object. The examples below are generated directly from the delivery code, so the field names and casing are exact — note that post and account events use camelCase while import events use snake_case.
post.published / post.error
{
"event": "post.published",
"timestamp": "2026-06-10T15:00:04.812Z",
"data": {
"postId": "post_8f2a…",
"orgId": "org_5c1…",
"socialAccounts": [
{ "accountId": "acct_x_main", "network": "x", "username": "PostFuze", "platformPostId": "1799…" },
{ "accountId": "acct_linkedin_org", "network": "linkedin", "username": "PostFuze", "platformPostId": "urn:li:share:7203…" },
{ "accountId": "acct_ig_brand", "network": "instagram", "username": "postfuze.com", "error": "Media aspect ratio not supported" }
]
}
}Each entry in socialAccounts[] carries platformPostId on success or error on failure. A post.error event has the same shape, with every account carrying an error.
account.token_expired
{
"event": "account.token_expired",
"timestamp": "2026-06-10T15:01:00.140Z",
"data": {
"orgId": "org_5c1…",
"accountId": "acct_x_main",
"network": "x",
"username": "PostFuze",
"error": "refresh_token_revoked"
}
}import.completed / import.failed
{
"event": "import.completed",
"timestamp": "2026-06-10T15:05:22.000Z",
"data": {
"orgId": "org_5c1…",
"import_id": "imp_77b…",
"social_account_id": "acct_linkedin_org",
"status": "completed",
"imported": 142
}
}Import payloads use snake_case (import_id, social_account_id) and always include the org and a status. import.failed carries the same fields plus a top-level error; imported reflects however many posts were ingested before the failure.
{
"event": "import.failed",
"timestamp": "2026-06-10T15:05:22.000Z",
"data": {
"orgId": "org_5c1…",
"import_id": "imp_77b…",
"social_account_id": "acct_linkedin_org",
"status": "failed",
"imported": 18,
"error": "rate limited by provider after 18 posts"
}
}Verifying the signature
The X-Postfuze-Signature header has the form t=<unix>,v1=<hex>. Recompute the HMAC-SHA256 of the signed payload `${t}.${rawBody}` using your endpoint's signing secret, hex-encode it, and compare it to v1 with a constant-time function such as crypto.timingSafeEqual. Then check that t is recent (within ~5 minutes) to reject replayed deliveries. Always verify before parsing the JSON, against the raw body.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300; // reject deliveries older than 5 minutes
/** Returns true if the delivery is authentic and fresh. Pass the *raw* body string/Buffer. */
export function verifyPostfuzeSignature(rawBody: string, header: string | undefined, secret: string): boolean {
if (!header) return false;
// Parse "t=<unix>,v1=<hex>" into its parts.
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=', 2)));
const t = Number(parts.t);
const received = parts.v1;
if (!Number.isFinite(t) || !received) return false;
// Reject stale timestamps (replay protection).
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(received, 'hex');
// Lengths must match before timingSafeEqual, which throws on mismatched buffers.
return a.length === b.length && timingSafeEqual(a, b);
}
// Express example — note express.raw() so we hash the exact bytes we received.
app.post('/hooks/postfuze', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyPostfuzeSignature(req.body.toString('utf8'), req.header('X-Postfuze-Signature'), process.env.POSTFUZE_WEBHOOK_SECRET!);
if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event ...
res.sendStatus(200); // respond 2xx promptly; do heavy work async
});post.published)POST with an X-Postfuze-Signature headerRetry policy
Your endpoint must return a 2xx status within 30 seconds. Any 4xx, 5xx, or timeout is treated as a failure and retried. PostFuze makes up to 8 attempts with exponential backoff — the delay doubles from 1 minute up to a 1-hour cap— so a receiver that's briefly down has hours to recover. After the final attempt the delivery is marked failed and dropped (you can re-queue it from the dashboard).
| Setting | Value |
|---|---|
| Max attempts | 8 |
| Backoff | Exponential — 1min doubling, capped at 1h |
| Per-attempt timeout | 30 seconds |
| Success | Any 2xx response |
| Retried on | 4xx, 5xx, timeout, connection error |
Because retries can re-deliver an event, treat handlers as idempotent: dedupe on data.postId + event, or on data.import_id for import events. Respond 2xx immediately and do slow work out of band so you never hit the 30-second timeout.
Test your endpoint
Send a signed sample event to an endpoint with POST /webhooks/{id}/test. It delivers synchronously (using the same signing and headers as a real delivery) and returns whether your receiver accepted it — handy for verifying connectivity before you rely on it.
curl -X POST https://api.postfuze.com/api/v1/webhooks/wh_3a8c…/test \
-H "Authorization: Bearer sk_live_…"{
"event": "webhook.test",
"delivered": true,
"response_status": 200,
"signed": true
}Managing endpoints
List endpoints with GET /webhooks, fetch one with GET /webhooks/{id}, change its url, events, description, or pause/resume delivery via is_active with PATCH /webhooks/{id}, and remove it with DELETE /webhooks/{id}.
Revealing & rotating the signing secret
The secret is returned in the 201 response when you create the endpoint. From the Webhooks dashboard you can also revealit again at any time (it's stored encrypted, not hashed) or rotate it in place — rotating mints a fresh whsec_… and the old secret stops signing immediately. Over the REST API a PATCH still does not change the secret (the patchable fields are url, events, description and is_active only).
Whichever way you roll it, cut over without dropping in-flight deliveries: configure your receiver to accept either the old or the new secret during the switch — verify against the new secret first and fall back to the old one — then drop the old secret once traffic confirms on the new one. Send a signed webhook.test with POST /webhooks/{id}/test to confirm. For API-only workflows that can't use the dashboard, the equivalent is to register a new endpoint and retire the old one the same way. Treat the secret like an API key — store it in a secret manager, never log it, and scope it to the one receiver that needs it.