Errors
Every failed request returns a structured error body using RFC 9457 problem details (which obsoletes RFC 7807), served with the Content-Type: application/problem+json media type. The HTTP status line tells you the category; the body tells you what went wrong and, where relevant, a stable machine-readable code, the offending field, or which publish target failed.
There are two layers of failure to reason about. Request errors are returned synchronously from the API call (a bad body, a missing key, a not-found id). Publishing errors happen asynchronously after a post is accepted, when a target fails to reach a platform — these never change the HTTP status of the original POST /posts call; they surface on the post’s targets[] instead.
The problem+json envelope
Every synchronous error shares the same top-level shape:
| Field | Type | Description |
|---|---|---|
type | string (URI) | A URI identifying the error category. Currently always about:blank, meaning the title is the canonical description. |
title | string | A short, stable, human-readable summary of the status — e.g. Bad Request, Not Found, Unprocessable Entity. Does not change between occurrences of the same status. |
status | number | The HTTP status code, repeated in the body so it survives logging and proxies. |
detail | string | null | A human-readable explanation specific to this occurrence — e.g. One or more target accounts were not found. |
code | string | Present on some errors: a stable, machine-readable identifier for the failure (e.g. not_found, payload_too_large). Branch on this — see the error-code catalog. |
errors | object[] | Present on validation failures only. An array of field-level issues (see Validation errors). |
Retry-After | header | On 429/503: seconds to wait before retrying. Delivered as a response header, not a body field. |
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "One or more target accounts were not found."
}Read the status, not the body, for control flow
status field), not on the detail string. detail is meant for humans and may be reworded at any time. title and status are stable.HTTP status codes
The API uses a small, conventional set of status codes. Each maps to a fixed title.
| Status | title | When it occurs |
|---|---|---|
400 | Bad Request | The request body was unparseable or structurally invalid — malformed JSON, or a value that failed an inline check such as a non-ISO scheduled_at. Field-level validation issues are attached under errors. |
401 | Unauthorized | The Authorization header is missing, malformed, or the API key is unknown, revoked, or expired. See Authentication. |
402 | Payment Required | A plan gate or hard quota blocked the request. Monthly API request exhaustion returns code/reason quota_exceeded with limit, used, and upgradeUrl. |
403 | Forbidden | The key is valid but lacks the scope required for the operation, or the resource belongs to another organization. |
404 | Not Found | No resource with that id exists in your organization, or it was soft-deleted. Returned for GET/DELETE on posts, accounts, media, and the OAuth connect session. |
405 | Method Not Allowed | The path exists but not for this HTTP method (e.g. PUT /posts). The response carries an Allow header listing the methods that are accepted, and the stable code method_not_allowed. |
409 | Conflict | The request collides with existing state — for example a uniqueness conflict that is not an idempotent replay. (Idempotent replays succeed with 200, not 409; see Idempotency.) |
413 | Payload Too Large | The request body exceeded the 1 MiB API limit, or a confirmed media object exceeded its kind’s size cap. Stable code payload_too_large. |
422 | Unprocessable Entity | The body parsed and is syntactically valid, but a referenced entity is wrong: a media item is missing or not ready, or a target account id does not belong to you. Schema validation failures also normalize to 422 with an errors array. |
429 | Too Many Requests | You exceeded a rate limit. The Retry-After response header (seconds) tells you when to retry. See Rate limits. |
500 | Internal Server Error | An unexpected, unhandled error on our side. The detail is generic; the failure is logged on our end. Safe to retry after a short backoff. |
5xx | (varies) | Transient infrastructure or upstream errors. Treat as retryable. |
Unknown routes return problem+json too
404 with code not_found; a known path hit with the wrong method returns 405 with code method_not_allowed and an Allow header. Both are rendered as application/problem+json, the same envelope as every other error — so you never get an HTML or bare-text error page from the API.Error-code catalog
Where a stable identifier is useful, the API and pipeline emit a machine-readable code alongside the human-readable detail. Branch on code (and the HTTP status), never on detail. There are two families: request codes on synchronous API errors, and per-target error_codes recorded on a publish target when it fails.
Request error codes
Emitted in the code field of a synchronous problem+json response.
code | Status | Meaning |
|---|---|---|
not_found | 404 | The request path matches no route — there is no such resource or endpoint. |
method_not_allowed | 405 | The path exists but not for this method. The Allow header lists the accepted methods. |
quota_exceeded | 402 | Your plan’s monthly API request quota is exhausted. The body includes reason, limit, used, and upgradeUrl. |
payload_too_large | 413 | The request body exceeded the 1 MiB ceiling (or a confirmed media object exceeded its kind’s size cap). |
Publish target error codes
Recorded in a target’s error_code (see per-target errorsbelow). These describe why a particular account’s publish failed, and whether PostFuze retries it.
error_code | Meaning | Retried automatically? |
|---|---|---|
rate_limited | The platform returned 429. PostFuze honors its Retry-After when provided. | Yes. |
token_expired | The platform rejected the access token (401/403). We attempt a refresh, then retry a small, capped number of times. | Yes, briefly (capped) — then surfaces as dead so you can reconnect the account. |
rejected_4xx (e.g. rejected_400) | The platform rejected the content with a non-auth 4xx (bad media, duplicate, policy violation). Permanent. | No — lands in dead immediately. |
poll_timeout | An async platform job (e.g. video transcoding) didn’t finish within the allotted polling window. Terminal regardless of transience. | No — the target becomes dead. |
network_error | A connection/transport failure with no HTTP status. | Yes — treated as transient. |
dead | Not an error_code itself but the terminal target status a failing target lands in once it exhausts retries (or hits a permanent error). The reason is in error_code. | No — terminal. Re-attempt by creating a new post for that account. |
Validation errors
When a request body fails schema validation, the response carries an errors array describing each offending field. Each entry is a validation issue with a path (the location in your JSON, as an array of keys/indices), a machine-readable code, and a message. A missing-or-malformed body on POST /posts returns 400 with these issues; schema failures raised deeper in the stack normalize to 422 with the same errors shape.
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request body.",
"errors": [
{
"code": "too_small",
"minimum": 1,
"type": "array",
"inclusive": true,
"path": ["accounts"],
"message": "Array must contain at least 1 element(s)"
},
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": ["containers", 0, "content"],
"message": "Expected string, received number"
}
]
}The path points straight at the field: ["containers", 0, "content"] means containers[0].content. Use it to attach errors to the right input without string-matching the message.
Common error situations by resource
These are the most common (status, detail) pairs you will encounter across the core resources.
| Resource | Status | Typical detail | Cause |
|---|---|---|---|
| Any (auth) | 401 | Provide your API key as a Bearer token. | No Authorization: Bearer sk_… header. |
| Any (auth) | 401 | Invalid or expired API key. | Key not found, revoked, or past its expiry. |
| Posts | 400 | Invalid request body. | Body missing or fails schema; see errors. |
| Posts | 400 | scheduled_at must be an ISO 8601 timestamp. | scheduled_at is present but not a valid date. |
| Posts | 422 | One or more media items are missing or not ready. | A referenced media id is unknown or not in ready/uploaded state. |
| Posts | 422 | One or more target accounts were not found. | An id in accounts[] is unknown or not in your org. |
| Posts | 404 | Post not found. | GET/DELETE on an unknown or deleted post. |
| Social accounts | 404 | Social account not found. | GET/DELETE on an unknown or disconnected account. |
Partial success and per-target errors
A POST /posts that targets several accounts is accepted as a single unit and returns 201 immediately — before any platform has been contacted. Publishing then happens asynchronously, one target per account, each retrying independently. Because targets are independent, a post can partially succeed: some accounts publish while others fail.
You observe the outcome by retrieving the post and inspecting its targets:
status— the target’s lifecycle state:pending,queued,published,failed(a transient failure that will retry),dead(a terminal failure, dead-lettered), orcanceled.error_code— a stable, machine-readable failure reason (see the table below).nulluntil something fails.error_message— the underlying message from the platform or transport, for debugging. Not stable; do not branch on it.
The post-level status rolls these up: published when every target succeeded, partial when at least one succeeded and at least one failed terminally, and failed when all targets failed or were canceled.
{
"id": "p1a2b3c4-0000-4a9f-9b1c-2e7d6f0a1234",
"status": "partial",
"targets": [
{
"id": "t_0001",
"platform": "x",
"status": "published",
"platform_post_id": "1799012345678901234",
"platform_post_url": "https://x.com/acme/status/1799012345678901234",
"error_code": null,
"error_message": null
},
{
"id": "t_0002",
"platform": "youtube",
"status": "dead",
"platform_post_id": null,
"platform_post_url": null,
"error_code": "token_expired",
"error_message": "401 Unauthorized: The access token has expired."
}
]
}The error_code values are assigned by PostFuze when a target fails:
error_code | Meaning | Retried automatically? |
|---|---|---|
token_expired | The platform rejected the access token (401). We attempt a token refresh, then retry a small, capped number of times. | Yes, briefly (capped) — then surfaces as dead so you can reconnect the account. |
rate_limited | The platform returned 429. We honor its Retry-After when provided. | Yes. |
upstream_5xx (e.g. upstream_502) | The platform returned a 5xx. Treated as transient. | Yes. |
rejected_4xx (e.g. rejected_400) | The platform rejected the content with a non-auth 4xx (bad media, duplicate, policy violation). Treated as permanent. | No — lands in dead immediately. |
poll_timeout | An async platform job (e.g. video processing) didn’t complete within the polling window. Terminal regardless of transience. | No — the target becomes dead. |
network_error | A connection/transport failure with no HTTP status. Treated as transient so a blip does not kill the post. | Yes. |
Transient failures back off with full-jitter exponential delay (capped at one hour) until they either succeed or exhaust the target’s retry budget, at which point the target becomes dead. If you have webhooks configured, a post.published event fires once at least one target succeeds, and post.error fires when all targets fail.
Handling errors in code
A robust client branches on the HTTP status, reads detail for context, walks errors for validation problems, and retries only the statuses that are safe to retry.
type Problem = {
type: string;
title: string;
status: number;
detail?: string;
errors?: { path: (string | number)[]; code: string; message: string }[];
};
export class PostfuzeError extends Error {
// retryAfter comes from the Retry-After response HEADER (429/503), not the problem body.
constructor(readonly problem: Problem, readonly retryAfter?: number) {
super(problem.detail ?? problem.title);
this.name = 'PostfuzeError';
}
get status() {
return this.problem.status;
}
}
export async function request(path: string, init: RequestInit = {}) {
const res = await fetch(`https://api.postfuze.com/api/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.POSTFUZE_API_KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
});
if (res.ok) return res.json();
// Errors are problem+json; parse defensively in case a proxy returns plain text.
const problem = (await res.json().catch(() => ({
type: 'about:blank',
title: res.statusText,
status: res.status,
}))) as Problem;
if (problem.status === 422 && problem.errors) {
for (const issue of problem.errors) {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
}
}
// Retry-After is a response header (seconds), not part of the problem body.
const retryAfter = Number(res.headers.get('retry-after')) || undefined;
throw new PostfuzeError(problem, retryAfter);
}
// Retry only the statuses that are safe to retry.
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
const status = err instanceof PostfuzeError ? err.status : 500;
if (i >= attempts - 1 || !RETRYABLE.has(status)) throw err;
const retryAfter = err instanceof PostfuzeError ? err.retryAfter : undefined;
const delayMs = (retryAfter ?? 2 ** i) * 1000;
await new Promise((r) => setTimeout(r, delayMs));
}
}
}Retry safely: 4xx is on you, 5xx and 429 are on us
4xx responses. A 400 or 422 means the request itself is wrong — retrying it unchanged will fail identically and only wastes quota. Fix the input and resend. 401/403 mean an auth or permission problem, not a transient one. By contrast, 429 and 5xx are transient: back off (honoring the Retry-After header when present) and retry. When retrying a POST /posts, always send the same Idempotency-Key so a retry can never double-publish.