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:

FieldTypeDescription
typestring (URI)A URI identifying the error category. Currently always about:blank, meaning the title is the canonical description.
titlestringA 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.
statusnumberThe HTTP status code, repeated in the body so it survives logging and proxies.
detailstring | nullA human-readable explanation specific to this occurrence — e.g. One or more target accounts were not found.
codestringPresent 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.
errorsobject[]Present on validation failures only. An array of field-level issues (see Validation errors).
Retry-AfterheaderOn 429/503: seconds to wait before retrying. Delivered as a response header, not a body field.
application/problem+json
{
  "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

Branch your code on the numeric HTTP status (or the 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.

StatustitleWhen it occurs
400Bad RequestThe 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.
401UnauthorizedThe Authorization header is missing, malformed, or the API key is unknown, revoked, or expired. See Authentication.
402Payment RequiredA plan gate or hard quota blocked the request. Monthly API request exhaustion returns code/reason quota_exceeded with limit, used, and upgradeUrl.
403ForbiddenThe key is valid but lacks the scope required for the operation, or the resource belongs to another organization.
404Not FoundNo 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.
405Method Not AllowedThe 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.
409ConflictThe 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.)
413Payload Too LargeThe request body exceeded the 1 MiB API limit, or a confirmed media object exceeded its kind’s size cap. Stable code payload_too_large.
422Unprocessable EntityThe 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.
429Too Many RequestsYou exceeded a rate limit. The Retry-After response header (seconds) tells you when to retry. See Rate limits.
500Internal Server ErrorAn 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

A request to a path that doesn’t exist returns 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.

codeStatusMeaning
not_found404The request path matches no route — there is no such resource or endpoint.
method_not_allowed405The path exists but not for this method. The Allow header lists the accepted methods.
quota_exceeded402Your plan’s monthly API request quota is exhausted. The body includes reason, limit, used, and upgradeUrl.
payload_too_large413The 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_codeMeaningRetried automatically?
rate_limitedThe platform returned 429. PostFuze honors its Retry-After when provided.Yes.
token_expiredThe 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_timeoutAn 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_errorA connection/transport failure with no HTTP status.Yes — treated as transient.
deadNot 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.

400 Bad Request
{
  "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.

ResourceStatusTypical detailCause
Any (auth)401Provide your API key as a Bearer token.No Authorization: Bearer sk_… header.
Any (auth)401Invalid or expired API key.Key not found, revoked, or past its expiry.
Posts400Invalid request body.Body missing or fails schema; see errors.
Posts400scheduled_at must be an ISO 8601 timestamp.scheduled_at is present but not a valid date.
Posts422One or more media items are missing or not ready.A referenced media id is unknown or not in ready/uploaded state.
Posts422One or more target accounts were not found.An id in accounts[] is unknown or not in your org.
Posts404Post not found.GET/DELETE on an unknown or deleted post.
Social accounts404Social 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), or canceled.
  • error_code — a stable, machine-readable failure reason (see the table below). null until 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.

GET /posts/{id} — a partial success
{
  "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_codeMeaningRetried automatically?
token_expiredThe 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_limitedThe 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_timeoutAn async platform job (e.g. video processing) didn’t complete within the polling window. Terminal regardless of transience.No — the target becomes dead.
network_errorA 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.

error-handling.ts
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

Do not blindly retry 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.