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

EventFires when
post.publishedA post reached at least one account successfully (post status published or partial).
post.errorA post failed to publish to every target (post status failed).
post.target.publishedA single channel (one account) of a post published — the per-account counterpart to post.published.
post.target.failedA single channel of a post failed permanently after retries.
account.token_expiredAn account's OAuth token could not be refreshed. Reconnect the account to resume publishing.
account.reauth_requiredAn account's grant was revoked and cannot be refreshed — a full reconnect is required.
account.connectedA social account finished the connect flow and is ready to publish.
account.disconnectedA social account was removed or revoked.
import.completedA historical-post import job finished successfully.
import.failedAn 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.

FieldTypeDescription
urlstring (URL)HTTPS endpoint that receives deliveries. Required.
eventsstring[] (min 1)Event names to subscribe to. Required.
descriptionstringOptional human-readable label.
signing_secretstringOptional. Provide your own secret, or let PostFuze generate a whsec_… one.
curl
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"
  }'
201 Created
{
  "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/json
  • User-Agent: Postfuze-Webhooks/1.0
  • X-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

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

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

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

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

verify.ts
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
});
1
An event occurs (e.g. post.published)
PostFuze signs `${t}.${rawBody}` with HMAC-SHA256 using your endpoint secret, stamping the time.
2
Delivers POST with an X-Postfuze-Signature header
Your endpoint must return a 2xx within 30 seconds for the delivery to count as successful.
3
On failure, retries with exponential backoff
Any 4xx, 5xx, or timeout reschedules the attempt — backoff doubles from 1 min up to a 1-hour cap.
4
Stops at the first 2xx, or after 8 attempts
A success ends the loop. After 8 failed attempts (spanning ~2 hours) the delivery is marked failed and dropped.

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

SettingValue
Max attempts8
BackoffExponential — 1min doubling, capped at 1h
Per-attempt timeout30 seconds
SuccessAny 2xx response
Retried on4xx, 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
curl -X POST https://api.postfuze.com/api/v1/webhooks/wh_3a8c…/test \
  -H "Authorization: Bearer sk_live_…"
200 OK
{
  "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.