Skip to main content
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

{
  "statusCode": 403,
  "code": "PLAN_LIMIT_POSTS",
  "message": "Your plan allows 100 posts/month.",
  "limit": 100,
  "current": 100,
  "requestId": "req_01HSAB7N4P9K2D6CXEZTQVRMW3"
}
FieldTypeAlways presentNotes
statusCodenumberyesMirrors the HTTP status. Convenient for clients that strip the response envelope.
codestring (SCREAMING_SNAKE)yesStable, machine-readable. The contract you should switch on. Listed below.
messagestringyesOne-sentence English summary. Safe to surface to end users; do not parse.
requestIdstringyesForward this when you open a support ticket — it indexes the server-side log line.
extrasvariessometimesDomain 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

StatusMeaning
400Malformed request — invalid JSON, missing required field, or business rule violated (MUST_SPECIFY_EXACTLY_ONE, etc.).
401Missing or invalid Authorization: Bearer pb_live_… header.
403Authenticated, but the action is forbidden — wrong workspace, insufficient role, plan limit, or feature gate.
404Resource doesn’t exist (or has been soft-deleted). Also returned when crossing a workspace boundary, to avoid leaking existence.
409Conflict — duplicate slug, foreign-key violation, or platform-already-connected.
422Semantic validation failed — typically a per-platform rule (TikTok caption length, IG image aspect ratio).
429Rate limited. Respect the Retry-After response header (seconds).
500Internal error. Retry the request; if it persists, email [email protected] with the requestId.
503A 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

CodeHTTPMeaning
UNAUTHORIZED401No bearer token, or the token is malformed.
INVALID_API_KEY401Bearer token doesn’t match any active key. Re-issue from Settings → Developers.
FORBIDDEN_WORKSPACE403The API key is valid but not scoped to the workspace you’re targeting.
INSUFFICIENT_ROLE403Your workspace role can’t perform this action (e.g., CLIENT_REVIEWER posting).
FEATURE_NOT_AVAILABLE403Your plan doesn’t include this feature (APPROVALS, WHITE_LABEL, API, …).

Plan limits & billing

CodeHTTPExtrasMeaning
PLAN_LIMIT_POSTS403limit, currentMonthly post quota reached for the workspace owner’s plan.
PLAN_LIMIT_API_POSTS403limit, currentAPI-authenticated post quota reached. Upgrade to Developer for per-account pricing.
PLAN_LIMIT_WORKSPACES403limit, currentPlan’s workspace cap reached. Add a paid workspace add-on, or upgrade.
WORKSPACE_REQUIRES_PAID_ADD_ON402Action requires the monthly per-workspace add-on. POST to /api/billing/workspace-add-on/checkout.
WORKSPACE_BILLING_FAILED402stripeMessageStripe rejected the charge for the workspace add-on. Update payment method.
X_PAYMENT_METHOD_REQUIRED402Connecting an X account needs a card on file (X API is metered).
DEVELOPER_PAYMENT_METHOD_REQUIRED402Developer-plan connection past the free quota needs a card on file.

Posts & publishing

CodeHTTPExtrasMeaning
VALIDATION_FAILED400details[]Body or query failed Zod / class-validator. details lists per-field errors.
MUST_SPECIFY_EXACTLY_ONE400fields[]Mutually exclusive fields — pass one, not both (e.g. mediaIds vs mediaItems).
TARGET_NOT_PUBLISHED400Action requires a PUBLISHED target (e.g., retry first-comment).
NO_EXTERNAL_ID400Target was never published to the platform — nothing to act on.
NO_COMMENT_TO_RETRY400No first-comment text saved on this target.
NO_ERROR_TO_RETRY400First comment already posted successfully — nothing to retry.
RETRY_COOLDOWN400retryAfterMsRetried too quickly. Wait the cooldown out, then call again.
PLATFORM_ALREADY_CONNECTED409accountIdThis platform identity is already connected to the workspace.

Media

CodeHTTPExtrasMeaning
URL_INGEST_DISABLED400URL ingest is disabled on this Postbreeze instance.
URL_REJECTED400reasonURL failed the SSRF allow-list (private IP, blocked host, bad scheme).
UNSUPPORTED_MIME400mimeFile type isn’t supported for the chosen platform / asset slot.
FILE_TOO_LARGE400sizeBytes, maxBytesFile exceeds per-asset size cap.
EMPTY_BODY400Upload completed but returned 0 bytes.
FETCH_FAILED400status?, reason?URL ingest couldn’t fetch the source. Verify the URL is reachable.
STORAGE_LIMIT403usedBytes, maxBytesWorkspace owner’s storage quota reached. Delete media or upgrade.
STORAGE_WRITE_FAILED503R2 / S3 write failed mid-stream. Transient — retry.
MEDIA_IN_USE409postCountCan’t delete an asset that’s attached to scheduled posts.

Generic & infrastructure

CodeHTTPMeaning
CONFLICT409A row with the same unique key already exists (duplicate slug, e-mail, etc.).
FK_VIOLATION409Referenced row no longer exists — likely a race with a delete.
NOT_FOUND404Row not found. We return 404 (not 403) when crossing workspaces to avoid leaks.
RETRY503Transient Postgres serialization conflict. Safe to retry immediately.
INTERNAL_ERROR500Unhandled server error. Always log the requestId and retry with backoff.

Handling errors in code

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;
  }
}

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.
PatternCodesStrategy
Safe to retry immediatelyRETRY, STORAGE_WRITE_FAILED, network timeoutsTransient — retry once or twice, then back off.
Retry after a delay429 rate-limit, RETRY_COOLDOWNHonour Retry-After header (seconds) or retryAfterMs field. Don’t retry sooner — you’ll get throttled longer.
Retry with exponential backoff500 INTERNAL_ERROR, 503 integrationsStart at 1s, double each attempt, cap at 30s, max 5 attempts.
Never retry — fix and resubmit400, 401, 403, 404, 409, 422The 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. The 429 response always includes Retry-After. A drop-in retry helper:
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.
    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.