# Error handling

Read the error envelope, branch on its stable type and code, and retry publishing failures.

import { Tab, Tabs } from 'fumadocs-ui/components/tabs';

Almost every non-`2xx` response is a flat JSON envelope with a stable `type` and `code`. Branch on those, never on the `error` text, and read `platformError` when a platform rejected the request.

## Read the error envelope

A request that leaves out a required field returns `400`:

```json
{
  "error": "budgetAmount is required",
  "type": "invalid_request_error",
  "code": "missing_required_field",
  "param": "budgetAmount"
}
```

| Field | Stable | Description |
|-------|--------|-------------|
| `error` | No | Human-readable message, reworded between releases. Display it, never branch on it. Kept so `response.error` keeps working. |
| `type` | Yes | Error class: `invalid_request_error`, `authentication_error`, `permission_error`, `not_found`, `rate_limit_error`, `platform_error` or `api_error`. |
| `code` | Yes | Machine-readable code such as `missing_required_field` or `ads_connection_required`. |
| `param` | Yes | The field or query parameter at fault, dotted when nested (`images.square`). |
| `details` | Yes | Extra structured context, such as field-level validation errors. |
| `platform` | Yes | On `platform_error` only: the platform that rejected the request (`meta`, `google`, `tiktok`). |
| `platformError` | Yes | On `platform_error` only: the platform's raw payload, unchanged. |

Fields are top-level, never nested under `details`. These older responses carry only `error`, sometimes `details`, so fall back to the status:

- the API-key `429` on [rate limits](/guides/rate-limits#if-it-fails)
- the plan-cap `403` from `POST /v1/profiles`
- the invalid `timezone` `400` from `POST /v1/posts`

## Branch on the type

| Type | Default status | When it happens |
|------|----------------|-----------------|
| `invalid_request_error` | 400 / 409 / 422 | Missing or wrong fields, invalid JSON, unmet preconditions, or an account that needs reconnecting. |
| `authentication_error` | 401 | Missing or invalid API key. |
| `permission_error` | 403, or 402 on a billing gate | Valid key, but the feature is off for the team (ads on a legacy AppSumo account), the key's resource group is disabled, or billing closed the gate. |
| `not_found` | 404 | The resource does not exist or this API key cannot access it. |
| `rate_limit_error` | 429 | Rate limit exceeded ([rate limits](/guides/rate-limits)). |
| `platform_error` | 502, or the platform's 4xx | A platform (Meta, Google, TikTok, LinkedIn, Pinterest, X) rejected the request. |
| `api_error` | 500 / 503 | Server-side error, or an upstream service or database is down. |

## Branch on the code

| Code | Type | Meaning |
|------|------|---------|
| `missing_required_field` | invalid_request_error | A required body or query field is missing. `param` names it. |
| `invalid_field_value` | invalid_request_error | Wrong type, format or enum value. `param` names it. |
| `mutually_exclusive_fields` | invalid_request_error | Two incompatible fields were both sent (`creatives[]` and `adSetId`). |
| `invalid_json_body` | invalid_request_error | The body is not valid JSON. |
| `missing_credentials` | authentication_error | No `Authorization` header. |
| `invalid_credentials` | authentication_error | The key is invalid, revoked or expired. |
| `ads_addon_required`, `feature_not_available` | permission_error | Ads, or the feature, are not enabled for the team. |
| `insufficient_permissions` | permission_error | On a restricted `zrk_` key, `required_group` names the [resource group](/multi-tenant#scoped-api-keys) it opted out of. On a dashboard user, the role forbids it. |
| `payment_required` | permission_error | `402`: no payment method for a paid action, such as an SMS sender ID. |
| `account_not_found`, `ad_not_found`, `post_not_found`, `audience_not_found` | not_found | That account, ad, post or audience is unknown or not accessible. |
| `linked_account_required` | invalid_request_error | A required linked account is missing (Instagram ads need a linked Facebook account). |
| `ads_connection_required` | invalid_request_error | `409`: the account is inactive or needs reconnecting. Also used for endpoint-specific connection preconditions (check the endpoint's status). |
| `instagram_business_account_unresolved` | invalid_request_error | No Instagram Business Account id on the linked Page. The user links Instagram to the Page in Meta Business Settings. |
| `missing_square_image` | invalid_request_error | Google Display needs both `images.landscape` and `images.square`; only one was sent. |
| `ad_not_commentable` | invalid_request_error | `GET /v1/ads/{adId}/comments`: the creative has no commentable post (Story ads, Dynamic Product Ads). |
| `queue_slot_conflict` | invalid_request_error | `409` from `PUT /v1/posts/{postId}`: the new `scheduledFor` is taken by another post in the same queue. |
| `rate_limited` | rate_limit_error | This endpoint's limit, or an upstream quota ([upstream `429`s](/guides/rate-limits#upstream-429s-keep-the-rate_limited-code)). Wait `Retry-After` seconds when present. |
| `platform_api_error` | platform_error | The platform rejected the call. Read `platform` and `platformError`. |
| `internal_error` | api_error | Server-side error. |
| `temporarily_unavailable` | api_error | `503`: upstream service or database down. `Retry-After` is the minimum delay in seconds. |

Some codes are UPPER_CASE, under the same stability rule:

- **Shared registry:** `INVALID_REDIRECT_URL`, `PLATFORM_BETA_RESTRICTED`, `PROFILE_OVER_LIMIT`, `ACCOUNT_NOT_ENABLED_FOR_POSTING`, `PLATFORM_DISABLED`.
- **One endpoint:** documented there, such as the `403` `ACCOUNT_DISCONNECTED` from `POST /v1/posts` when a target account's connection has expired.
- **Inbox send:** `PLATFORM_LIMITATION`, `TEMPLATE_REQUIRED`, `DIRECT_SEND_BLOCKED` and the rest, on [send inbox message](/messages/send-inbox-message). There `PLATFORM_NOT_SUPPORTED` is uppercase, though lowercase elsewhere.

Compare `code` exactly as documented, never lowercased.

## Read a platform error

When a platform rejects a request, `platformError` holds its payload:

```json
{
  "error": "Google rejected the ad: NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET",
  "type": "platform_error",
  "code": "platform_api_error",
  "platform": "google",
  "platformError": {
    "code": 3,
    "message": "Request contains an invalid argument.",
    "details": [ { "errors": [ { "errorCode": { "assetError": "NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET" }, "message": "Too few." } ] } ]
  }
}
```

- **Status:** the platform's `4xx` for bad input (fixable by you); `502` for a platform `5xx` or no status (transient).
- **`platform`:** lets one handler branch by integration.
- **`platformError`:** its codes, for retries or user messages (Meta: `error_subcode`, `error_user_title`, `error_user_msg`).

## Retry a failed post

A scheduled post that fails does not return an envelope. It ends with `status: "partial"` (some platforms published) or `status: "failed"` (none did). Retry it with `POST /v1/posts/{postId}/retry`: only the failed platforms run again.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
import Zernio from '@zernio/node';

const zernio = new Zernio();

const { data: retried } = await zernio.posts.retryPost({
  path: { postId: '65f1c0a9e2b5af0012ab34cd' }
});

console.log(retried.post.status);
```
</Tab>
<Tab value="Python">
```python
from zernio import Zernio

client = Zernio()

retried = client.posts.retry_post(post_id="65f1c0a9e2b5af0012ab34cd")

print(retried["post"]["status"])
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/posts/65f1c0a9e2b5af0012ab34cd/retry" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```
</Tab>
</Tabs>

```json
{
  "message": "Post published successfully",
  "post": {
    "_id": "65f1c0a9e2b5af0012ab34cd",
    "status": "published",
    "platforms": [
      { "platform": "instagram", "status": "published", "platformPostUrl": "https://www.instagram.com/p/C1a2B3c4D5e/" }
    ]
  }
}
```

Each failed entry in `platforms[]` carries `errorMessage`, `errorCategory` and `errorSource`. The [post lifecycle guide](/guides/post-lifecycle#per-platform-status-and-errors) lists every category and its fix.

## Check account health before publishing

`GET /v1/accounts/health` shows which accounts can post and which need reconnecting.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data: health } = await zernio.accounts.getAllAccountsHealth();

console.log(health.summary.needsReconnect);
```
</Tab>
<Tab value="Python">
```python
health = client.accounts.get_all_accounts_health()

print(health["summary"]["needsReconnect"])
```
</Tab>
<Tab value="curl">
```bash
curl "https://zernio.com/api/v1/accounts/health" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```
</Tab>
</Tabs>

```json
{
  "summary": { "total": 1, "healthy": 0, "warning": 0, "error": 1, "needsReconnect": 1 },
  "accounts": [
    {
      "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
      "platform": "instagram",
      "status": "error",
      "canPost": false,
      "needsReconnect": true,
      "issues": ["Token expired"]
    }
  ]
}
```

Filter with `profileId`, `platform` or `status` (`healthy`, `warning`, `error`). The [health endpoint](/accounts/get-all-accounts-health) lists every field.

## How it behaves

- **Codes are stable.** `code` values are lowercase snake_case (`invalid_field_value`). New ones appear any time; removing or renaming one is a breaking change.
- **207 is a 2xx.** `POST /v1/posts` with `publishNow: true`, and `POST /v1/posts/{postId}/retry`, return `207` when the post was created but a platform failed. `fetch(...).ok` is `true` and axios resolves: branch on the status or `post.status`.
- **Unknown parameters are dropped, not rejected.** Fields are camelCase (`accountId`, `profileId`, `scheduledFor`, `publishNow`). `GET /v1/accounts?profile_id=...` returns every team account; a `scheduled_for` leaves the post a draft. Check the response body.
- **Real snake_case names** are spelled that way in the reference: `redirect_url` on the [connect endpoints](/connect/get-connect-url), `restrict_sr` on Reddit search, the filters on [`GET /v1/logs`](/logs/list-logs).
- **Log the whole envelope**, including `platformError`, with the request body. Use [webhooks](/webhooks) instead of polling for status.

## If it fails

| Error | Cause | Fix |
|---|---|---|
| `402` `PAYMENT_REQUIRED` on `POST /v1/profiles`, `GET /v1/connect/{platform}`, phone-number or WhatsApp number purchase | A billing gate closed. `reason` is `free_tier_exceeded`, `twitter_passthrough` or `enterprise_required`. | A card or plan, at `dashboard_url` ([connect failures](/guides/connecting-accounts#if-it-fails)). |
| `402` `analytics_addon_required` on every `/v1/analytics` endpoint | A legacy plan without analytics access. | Change plan. |
| `402` `payment_required` on `POST /v1/sms/sender-ids` | No payment method for a sender ID's carrier fees. | Add a card. |
| `402` with `error` alone on `POST /v1/posts/bulk-upload` or `POST /v1/posts/{postId}/retry` | The team's last payment failed. | Fix the payment. |
| `409` `code: "ads_connection_required"` | Account inactive or needing reconnection, not missing. [`POST /v1/posts/sync-external`](/analytics/sync-external-posts) uses it too. | Stop polling it until the customer reconnects, then read [`GET /v1/accounts`](/accounts/list-accounts) for its current ID. |
| `404` on an account ID | Unknown, inaccessible, or disconnected and removed. | Re-read `GET /v1/accounts` instead of polling a removed ID. |
| `503 temporarily_unavailable` (`type: "api_error"`, `code: "temporarily_unavailable"`) | Upstream down. A timed-out write may have completed upstream. | Wait `Retry-After` seconds. Check the resource before resubmitting; use [idempotency support](/guides/idempotency) where documented. |
| `503` on [ad-account creation](/ad-accounts/create-ad-account) | Meta and Google ad-account creation is not idempotent and never retried automatically. | Check before you replay it. |
| `429` | Rate limit exceeded. | Wait `Retry-After` seconds, then retry ([rate limits](/guides/rate-limits)). |
| Platform `4xx` / `502` | See [Read a platform error](#read-a-platform-error). | Fix the input on a `4xx`; retry a `502`. |

## Related

- [Post lifecycle](/guides/post-lifecycle): statuses, `errorCategory` and what each state allows.
- [Rate limits](/guides/rate-limits): the `429` body and headers.
- [Idempotency & safe retries](/guides/idempotency): retry a create without a duplicate.
- [Webhooks](/webhooks): delivery guarantees and retries.
- [Account health](/accounts/get-all-accounts-health): the full response.
- [Reporting bugs and missing features](/guides/feedback): when the error looks like ours, tell us with `POST /v1/feedback`.