code and statusCode — never on the
human-readable message, which can change between releases.
Response shape
| 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, …). |
/api/v1/*, dashboard endpoints, and
MCP-tool responses. There is no separate “user-facing” vs “API” shape.
Stability contract
codeandstatusCodeare stable. We will not change them without a major version bump. Add new codes to yourswitchas we ship new features; existing codes will keep their meaning.messageis not stable. Copy may be tightened, translated, or re-phrased between releases. Show it; never===it.- Extra fields are additive. A
PLAN_LIMIT_POSTSerror today carrieslimit+current; future versions may addresetAt. 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 [email protected] 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 onstatusCode 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
Retries
Network calls fail. The table below tells you whichcode 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. |
429
response always includes Retry-After.
A drop-in retry helper:
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:
-
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 sameclientReferenceIdreturns the original post instead of creating a duplicate. -
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.
cancel and update, the operation is naturally idempotent — the
second call is a no-op on already-cancelled / unchanged data.
Webhooks
Postbreeze sends webhooks forpost.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
DEADand surfaces in Settings → Webhooks → Deliveries. No further attempts. - Signature: every request carries an
X-Postbreeze-Signatureheader —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 onmessage. 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 carrylimitandcurrent— 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
clientReferenceIdfor 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/v2before we break anything inv1.