Rate limits, retries & idempotency
PostFuze sits between your application and eleven platforms that each enforce their own, frequently-changing rate limits. Rather than make you reason about all of them, PostFuze manages limits intelligently on your behalf: it queues each publish, honors every platform's back-pressure signals, and retries transient failures with backoff — so a temporary 429 on one account never breaks the rest of your post.
API rate limits
Calls to the PostFuze REST API are limited per organization using fixed windows. Each limiter is a named bucket, so a burst of writes on one path never starves another — a generous global ceiling covers every authenticated call, and tighter buckets protect expensive and state-mutating paths (posts, media, accounts/imports, scheduling, team, profiles, and webhooks). When you exceed a bucket the API responds with 429 Too Many Requests and a Retry-After header.
| Scope | Methods | Limit | Window |
|---|---|---|---|
| All authenticated calls (global) | Any | 600 | 60 s |
/posts/* | POST, PATCH, DELETE | 120 | 60 s |
/media/* | POST, DELETE | 60 | 60 s |
/social-accounts/* (incl. imports) | POST, DELETE | 20 | 60 s |
/scheduling/* | PUT | 60 | 60 s |
/team/* | POST, DELETE | 30 | 60 s |
/profiles/* | POST, PATCH, DELETE | 30 | 60 s |
/webhooks/* | POST, PATCH, DELETE | 60 | 60 s |
/social-accounts/bluesky/connect | POST | 10 | 60 s |
The buckets stack: a POST /posts counts against both the global ceiling and the posts:write bucket, and is rejected as soon as either is exhausted. The write buckets only count their named method (e.g. the media bucket counts POST uploads/confirms, not GET /media), so reads are effectively bounded only by the global limit. The Bluesky connect path forwards a handle and app password to Bluesky, so it carries its own tight per-org limiter independent of the others.
The MCP endpoint at /api/mcp also has a pre-authenticated IP limiter of 120 requests per 60 seconds. Once an MCP tool calls the REST API, the same per-organization buckets above apply to the underlying operation.
Monthly publish cap
Monthly API request quota
402 Payment Required with code/reason quota_exceeded, plus limit, used, and upgradeUrl fields. Unlike per-window 429s, this does not clear until the billing period resets or the org upgrades.Rate-limit response headers
Every call that passes through a limiter carries standard headers describing the bucket it was counted against, so you can pace yourself before hitting a 429. When you do exhaust a window, the Retry-After response header tells you exactly how long to wait — it is a header only, not echoed in the JSON body.
| Header | When | Meaning |
|---|---|---|
X-RateLimit-Limit | Every limited call | The bucket’s ceiling for the current window (e.g. 600). |
X-RateLimit-Remaining | Every limited call | Calls left in the current window before this bucket rejects. |
Retry-After | On 429 only | Seconds to wait before the window resets. (Header only — not echoed in the JSON body.) |
The headers reflect the last bucket the request was counted against; when several limiters apply, watch X-RateLimit-Remaining approaching zero and slow down rather than racing to the limit. A 429 is always safe to retry once Retry-After elapses — and when retrying a POST /posts, reuse the same Idempotency-Key so the retry can never double-publish.
{
"type": "about:blank",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. Slow down and retry after the window resets."
}The same 429 response carries Retry-After: 42 as an HTTP header. Branch on the numeric status (or the header), not on the detail string — see Errors for the full envelope.
Intelligent rate limiting
Calls to the PostFuze API are accepted quickly; the actual platform publish happens asynchronously in the background. Each account's target is processed independently, which lets PostFuze pace work per platform and per account instead of hammering an upstream API. When a platform pushes back, PostFuze reacts to the signal rather than retrying blindly:
- Honors
Retry-After— when a platform returns a429with a retry hint, PostFuze waits exactly that long before the next attempt. - Backs off on
5xxand network errors so transient upstream trouble resolves on its own. - Isolates accounts — one rate-limited account does not delay the others on the same post.
Retries & backoff
When a publish attempt fails, PostFuze classifies the error to decide whether it is worth retrying. Transient errors are retried with full-jitter exponential backoff — roughly 30s × 2^(attempt-1) with random jitter, capped at one hour. A Retry-After hint from the platform overrides the computed delay.
| Upstream result | Classification | Behavior |
|---|---|---|
429 Too Many Requests | rate_limited (transient) | Retry, honoring Retry-After if present. |
5xx | upstream_5xx (transient) | Retry with exponential backoff. |
401 Unauthorized | token_expired (transient) | Refresh the token and retry — but only a couple of times, then pause so you can reconnect (emits account.token_expired). |
| Network / connection error | network_error (transient) | Retry with backoff — a blip won't kill the post. |
Other 4xx (e.g. 400, 422) | rejected_4xx (permanent) | No retry — the request is invalid for that platform. Target goes to a terminal failure. |
A target keeps retrying until it succeeds or exhausts its attempt budget, at which point it becomes deadand the failure surfaces in the post's error_code / error_message and (if every target dies) a post.error webhook. See Post lifecycle, Errors for the full error_code catalog, and Webhooks.
Idempotency
Network calls are never perfectly reliable, so POST /posts is safe to retry. Attach an idempotency key and a replayed request returns the original post instead of creating a duplicate. There are two equivalent ways to supply it:
- The
Idempotency-Keyrequest header, or - An
external_reffield in the body (max 255 chars).
Use a unique value per logical post — a UUID you generate, or your own record ID. If PostFuze has already seen the key, it returns the existing post with 200 OK; a first-time key returns 201 Created. The full rules — including which other write endpoints honor Idempotency-Key — are on the Idempotency page.
curl https://api.postfuze.com/api/v1/posts \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f3c0a7e-2b9d-4e1a-9c84-1f0b6d2e7a11" \
-d '{
"content": "Safe to retry — this will only ever create one post.",
"accounts": ["acct_x_main"]
}'Replaying the same key returns the same post. The status line tells you which happened:
{
"id": "post_8f2a…",
"status": "queued",
"external_ref": "5f3c0a7e-2b9d-4e1a-9c84-1f0b6d2e7a11",
"containers": [{ "id": "ctr_0", "position": 0, "role": "main", "content": "Safe to retry — this will only ever create one post." }],
"targets": [{ "id": "tgt_x", "social_account_id": "acct_x_main", "platform": "x", "status": "queued" }]
}Idempotency also extends to publishing: once a target captures a platform_post_id, a retry finalizes the existing post rather than publishing again — so even an internal retry can never double-post to a platform.
Partial success
Because every account is its own target with its own retry budget, a single post can land on some platforms while still retrying — or permanently failing — on others. This is first-class, not an error condition:
- Some targets
published, somedead→ the post ispartialand a singlepost.publishedwebhook fires (at least one account is live). - Every target
dead→ the post isfailedandpost.errorfires. - Inspect each account's outcome in
targets[]:platform_post_id/platform_post_urlon success,error_code/error_messageon failure.
Always read the per-target array rather than the single post status when you need to know exactly which accounts succeeded. To re-attempt a failed account, create a new post targeting just that account (with a fresh idempotency key).
Error responses
Synchronous API errors (bad input, missing resources, auth) are returned as application/problem+json with an HTTP status, a title, and a detail. Validation errors include a structured errors array. See Errors for the full envelope and the stable code catalog.
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "One or more target accounts were not found."
}