> ## Documentation Index
> Fetch the complete documentation index at: https://docs.postbreeze.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling

> Predictable failure modes for the Postbreeze REST API.

Every Postbreeze endpoint returns the same JSON envelope on failure.
Branch your client code on `code` and `statusCode` — never on the
human-readable `message`, which can change between releases.

## Response shape

```json theme={null}
{
  "statusCode": 403,
  "code": "PLAN_LIMIT_POSTS",
  "message": "Your plan allows 100 posts/month.",
  "limit": 100,
  "current": 100,
  "requestId": "req_01HSAB7N4P9K2D6CXEZTQVRMW3"
}
```

| Field        | Type                        | Always present | Notes                                                                                        |
| ------------ | --------------------------- | -------------- | -------------------------------------------------------------------------------------------- |
| `statusCode` | `number`                    | yes            | Mirrors the HTTP status. Convenient for clients that strip the response envelope.            |
| `code`       | `string` (SCREAMING\_SNAKE) | yes            | Stable, machine-readable. The contract you should switch on. Listed below.                   |
| `message`    | `string`                    | yes            | One-sentence English summary. Safe to surface to end users; do not parse.                    |
| `requestId`  | `string`                    | yes            | Forward this when you open a support ticket — it indexes the server-side log line.           |
| *extras*     | varies                      | sometimes      | Domain errors include structured context (`limit`, `current`, `retryAfterMs`, `details`, …). |

The envelope is identical across `/api/v1/*`, dashboard endpoints, and
MCP-tool responses. There is no separate "user-facing" vs "API" shape.

## Stability contract

* **`code` and `statusCode` are stable.** We will not change them without
  a major version bump. Add new codes to your `switch` as we ship new
  features; existing codes will keep their meaning.
* **`message` is not stable.** Copy may be tightened, translated, or
  re-phrased between releases. Show it; never `===` it.
* **Extra fields are additive.** A `PLAN_LIMIT_POSTS` error today
  carries `limit` + `current`; future versions may add `resetAt`. Treat
  the envelope as open-shape.

## HTTP status codes

| Status | Meaning                                                                                                                          |
| ------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Malformed request — invalid JSON, missing required field, or business rule violated (`MUST_SPECIFY_EXACTLY_ONE`, etc.).          |
| `401`  | Missing or invalid `Authorization: Bearer pb_live_…` header.                                                                     |
| `403`  | Authenticated, but the action is forbidden — wrong workspace, insufficient role, plan limit, or feature gate.                    |
| `404`  | Resource doesn't exist (or has been soft-deleted). Also returned when crossing a workspace boundary, to avoid leaking existence. |
| `409`  | Conflict — duplicate slug, foreign-key violation, or platform-already-connected.                                                 |
| `422`  | Semantic validation failed — typically a per-platform rule (TikTok caption length, IG image aspect ratio).                       |
| `429`  | Rate limited. Respect the `Retry-After` response header (seconds).                                                               |
| `500`  | Internal error. Retry the request; if it persists, email `pontus@postbreeze.ai` with the `requestId`.                            |
| `503`  | A required integration (Stripe, R2, Postgres) is degraded. Transient — retry with backoff.                                       |

## Error codes

The table below covers every domain code currently emitted by the API.
Codes are grouped by area; the **HTTP** column tells you which status
they ride on so you can short-circuit on `statusCode` first when that's
all you need.

### Authentication & access

| Code                    | HTTP | Meaning                                                                             |
| ----------------------- | ---- | ----------------------------------------------------------------------------------- |
| `UNAUTHORIZED`          | 401  | No bearer token, or the token is malformed.                                         |
| `INVALID_API_KEY`       | 401  | Bearer token doesn't match any active key. Re-issue from **Settings → Developers**. |
| `FORBIDDEN_WORKSPACE`   | 403  | The API key is valid but not scoped to the workspace you're targeting.              |
| `INSUFFICIENT_ROLE`     | 403  | Your workspace role can't perform this action (e.g., `CLIENT_REVIEWER` posting).    |
| `FEATURE_NOT_AVAILABLE` | 403  | Your plan doesn't include this feature (`APPROVALS`, `WHITE_LABEL`, `API`, …).      |

### Plan limits & billing

| Code                                | HTTP | Extras             | Meaning                                                                                             |
| ----------------------------------- | ---- | ------------------ | --------------------------------------------------------------------------------------------------- |
| `PLAN_LIMIT_POSTS`                  | 403  | `limit`, `current` | Monthly post quota reached for the workspace owner's plan.                                          |
| `PLAN_LIMIT_API_POSTS`              | 403  | `limit`, `current` | API-authenticated post quota reached. Upgrade to Developer for per-account pricing.                 |
| `PLAN_LIMIT_WORKSPACES`             | 403  | `limit`, `current` | Plan's workspace cap reached. Add a paid workspace add-on, or upgrade.                              |
| `WORKSPACE_REQUIRES_PAID_ADD_ON`    | 402  |                    | Action requires the monthly per-workspace add-on. POST to `/api/billing/workspace-add-on/checkout`. |
| `WORKSPACE_BILLING_FAILED`          | 402  | `stripeMessage`    | Stripe rejected the charge for the workspace add-on. Update payment method.                         |
| `X_PAYMENT_METHOD_REQUIRED`         | 402  |                    | Connecting an X account needs a card on file (X API is metered).                                    |
| `DEVELOPER_PAYMENT_METHOD_REQUIRED` | 402  |                    | Developer-plan connection past the free quota needs a card on file.                                 |

### Posts & publishing

| Code                         | HTTP | Extras         | Meaning                                                                           |
| ---------------------------- | ---- | -------------- | --------------------------------------------------------------------------------- |
| `VALIDATION_FAILED`          | 400  | `details[]`    | Body or query failed Zod / class-validator. `details` lists per-field errors.     |
| `MUST_SPECIFY_EXACTLY_ONE`   | 400  | `fields[]`     | Mutually exclusive fields — pass one, not both (e.g. `mediaIds` vs `mediaItems`). |
| `TARGET_NOT_PUBLISHED`       | 400  |                | Action requires a `PUBLISHED` target (e.g., retry first-comment).                 |
| `NO_EXTERNAL_ID`             | 400  |                | Target was never published to the platform — nothing to act on.                   |
| `NO_COMMENT_TO_RETRY`        | 400  |                | No first-comment text saved on this target.                                       |
| `NO_ERROR_TO_RETRY`          | 400  |                | First comment already posted successfully — nothing to retry.                     |
| `RETRY_COOLDOWN`             | 400  | `retryAfterMs` | Retried too quickly. Wait the cooldown out, then call again.                      |
| `PLATFORM_ALREADY_CONNECTED` | 409  | `accountId`    | This platform identity is already connected to the workspace.                     |

### Media

| Code                   | HTTP | Extras                  | Meaning                                                                |
| ---------------------- | ---- | ----------------------- | ---------------------------------------------------------------------- |
| `URL_INGEST_DISABLED`  | 400  |                         | URL ingest is disabled on this Postbreeze instance.                    |
| `URL_REJECTED`         | 400  | `reason`                | URL failed the SSRF allow-list (private IP, blocked host, bad scheme). |
| `UNSUPPORTED_MIME`     | 400  | `mime`                  | File type isn't supported for the chosen platform / asset slot.        |
| `FILE_TOO_LARGE`       | 400  | `sizeBytes`, `maxBytes` | File exceeds per-asset size cap.                                       |
| `EMPTY_BODY`           | 400  |                         | Upload completed but returned 0 bytes.                                 |
| `FETCH_FAILED`         | 400  | `status?`, `reason?`    | URL ingest couldn't fetch the source. Verify the URL is reachable.     |
| `STORAGE_LIMIT`        | 403  | `usedBytes`, `maxBytes` | Workspace owner's storage quota reached. Delete media or upgrade.      |
| `STORAGE_WRITE_FAILED` | 503  |                         | R2 / S3 write failed mid-stream. Transient — retry.                    |
| `MEDIA_IN_USE`         | 409  | `postCount`             | Can't delete an asset that's attached to scheduled posts.              |

### Generic & infrastructure

| Code             | HTTP | Meaning                                                                         |
| ---------------- | ---- | ------------------------------------------------------------------------------- |
| `CONFLICT`       | 409  | A row with the same unique key already exists (duplicate slug, e-mail, etc.).   |
| `FK_VIOLATION`   | 409  | Referenced row no longer exists — likely a race with a delete.                  |
| `NOT_FOUND`      | 404  | Row not found. We return 404 (not 403) when crossing workspaces to avoid leaks. |
| `RETRY`          | 503  | Transient Postgres serialization conflict. Safe to retry immediately.           |
| `INTERNAL_ERROR` | 500  | Unhandled server error. Always log the `requestId` and retry with backoff.      |

## Handling errors in code

<CodeGroup>
  ```ts Node.js (SDK) theme={null}
  import Postbreeze, { APIError } from "@postbreeze/node";

  const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY! });

  try {
    await postbreeze.posts.create({
      content: "Launching today 🚀",
      platforms: [{ accountId: "soc_…" }],
    });
  } catch (err) {
    if (!(err instanceof APIError)) throw err;

    switch (err.code) {
      case "PLAN_LIMIT_POSTS":
        // err.extras.limit, err.extras.current are present.
        alert(`You've used ${err.extras.current}/${err.extras.limit} this month.`);
        break;
      case "RETRY_COOLDOWN":
        await sleep(err.extras.retryAfterMs);
        // retry…
        break;
      case "INVALID_API_KEY":
        // Bubble up; user needs to re-issue.
        throw err;
      default:
        console.error(`API error [${err.code}] req=${err.requestId}: ${err.message}`);
        throw err;
    }
  }
  ```

  ```python Python theme={null}
  import os, time, httpx

  pb = httpx.Client(
      base_url="https://api.postbreeze.ai/api/v1",
      headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"},
  )

  r = postbreeze.post("/posts", json={
      "content": "Launching today 🚀",
      "platforms": [{"accountId": "soc_…"}],
  })

  if r.is_error:
      body = r.json()
      code = body["code"]
      if code == "PLAN_LIMIT_POSTS":
          raise RuntimeError(
              f"Used {body['current']}/{body['limit']} posts this month"
          )
      if code == "RETRY_COOLDOWN":
          time.sleep(body["retryAfterMs"] / 1000)
          # retry…
      if code == "INVALID_API_KEY":
          raise RuntimeError("Re-issue your API key in Settings → Developers")
      raise RuntimeError(
          f"API error [{code}] req={body['requestId']}: {body['message']}"
      )
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.postbreeze.ai/api/v1/posts \
    -H "Authorization: Bearer $POSTBREEZE_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"content":"Hello","platforms":[{"accountId":"soc_…"}]}'

  # 403 response:
  # {
  #   "statusCode": 403,
  #   "code": "PLAN_LIMIT_POSTS",
  #   "message": "Your plan allows 100 posts/month.",
  #   "limit": 100, "current": 100,
  #   "requestId": "req_01HSAB7N4P9K2D6CXEZTQVRMW3"
  # }
  ```
</CodeGroup>

## Retries

Network calls fail. The table below tells you which `code` values are
safe to retry verbatim, which need a delay, and which require user
action before they can succeed.

| Pattern                            | Codes                                             | Strategy                                                                                                         |
| ---------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Safe to retry immediately**      | `RETRY`, `STORAGE_WRITE_FAILED`, network timeouts | Transient — retry once or twice, then back off.                                                                  |
| **Retry after a delay**            | `429` rate-limit, `RETRY_COOLDOWN`                | Honour `Retry-After` header (seconds) or `retryAfterMs` field. Don't retry sooner — you'll get throttled longer. |
| **Retry with exponential backoff** | `500 INTERNAL_ERROR`, `503` integrations          | Start at 1s, double each attempt, cap at 30s, max 5 attempts.                                                    |
| **Never retry — fix and resubmit** | `400`, `401`, `403`, `404`, `409`, `422`          | The request is wrong, not the network. Retrying produces the same error.                                         |

Rate-limit windows are **60 req/min (burst)** and **1000 req/15min
(sustained)** — see [Rate limits](/concepts/rate-limits). The `429`
response always includes `Retry-After`.

A drop-in retry helper:

```ts theme={null}
async function withRetry<T>(fn: () => Promise<T>, attempts = 5): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (!(err instanceof APIError)) throw err;

      // Permanent: bail immediately.
      const permanent = ["VALIDATION_FAILED", "INVALID_API_KEY",
        "FORBIDDEN_WORKSPACE", "INSUFFICIENT_ROLE", "PLAN_LIMIT_POSTS",
        "PLAN_LIMIT_API_POSTS", "PLAN_LIMIT_WORKSPACES",
        "PLATFORM_ALREADY_CONNECTED", "MEDIA_IN_USE",
        "MUST_SPECIFY_EXACTLY_ONE"];
      if (permanent.includes(err.code)) throw err;

      // Honour an explicit Retry-After / retryAfterMs.
      const wait = err.extras.retryAfterMs
        ?? err.retryAfterHeader * 1000
        ?? Math.min(30_000, 1000 * 2 ** i);
      await new Promise((r) => setTimeout(r, wait));
    }
  }
  throw new Error("Exhausted retry attempts");
}
```

## Idempotency

`POST /api/v1/posts` and the other create endpoints **are not
automatically idempotent**. A network blip mid-request can leave you
unsure whether the post landed.

Two recommended defenses:

1. **Use `clientReferenceId`.** Pass a unique string (UUID, your own
   primary key) when creating a post. We dedupe within a 24-hour
   window — a retry with the same `clientReferenceId` returns the
   original post instead of creating a duplicate.

   ```ts theme={null}
   await postbreeze.posts.create({
     clientReferenceId: "your-post-id-42",
     content: "…",
     platforms: [{ accountId: "soc_…" }],
   });
   ```

2. **Search before retrying.** If a retry feels risky, query
   `GET /posts?workspaceId=…&scheduledAfter=…` before the second
   attempt and check whether the first one already wrote the row.

For `cancel` and `update`, the operation is naturally idempotent — the
second call is a no-op on already-cancelled / unchanged data.

## Webhooks

Postbreeze sends webhooks for `post.published`, `post.failed`,
`account.token_expired`, and `comment.received`. All delivery is
**at-least-once** — a single event may be delivered more than once if
your endpoint times out, returns a 5xx, or we don't receive a 2xx
within 10 seconds.

* **Dedupe on `event.id`** (a ULID). Store the last N event IDs you've
  processed and short-circuit duplicates.
* **Return 2xx fast.** Acknowledge first, process async. Anything over
  5 seconds eats into the next retry's window.
* **We back off** at 30s, 2min, 10min, 1hr, 6hr, 24hr. After six
  failures, the delivery is marked `DEAD` and surfaces in Settings →
  Webhooks → Deliveries. No further attempts.
* **Signature**: every request carries an `X-Postbreeze-Signature`
  header — `HMAC-SHA256(secret, rawBody)` in hex. Verify before
  trusting the body.

## Account health

`account.token_expired` fires when a connected social account's OAuth
token can no longer be refreshed (revoked, password changed, scope
removed). The account stays in the workspace but `SocialAccount.status`
flips to `TOKEN_EXPIRED` and publishing to that account begins to fail
with `503 PLATFORM_AUTH_FAILED`.

Recover by reconnecting the account from the dashboard. Scheduled posts
targeting it will retry automatically once the row returns to `ACTIVE`.

## Best practices

* **Branch on `code`, never on `message`.** Message text rotates;
  codes don't.
* **Always log `requestId`.** It's the only correlation key we have
  between your client and our server logs. Surface it in your own
  error reports.
* **Show plan-limit errors with context.** `PLAN_LIMIT_*` errors carry
  `limit` and `current` — surface "97/100 posts used this month"
  instead of a generic "quota reached." Most users self-serve upgrade
  when shown the number.
* **Don't retry 4xx.** Anything in the 400-range is your request being
  wrong, not our server. Retrying wastes quota and slows your user
  down.
* **Use `clientReferenceId` for create calls.** It's the single
  cheapest way to make your integration idempotent.
* **Pin to a major version.** All endpoints live under `/api/v1`. We
  will introduce `/api/v2` before we break anything in `v1`.
