# Errors, rate limits and idempotency

> Every error has the same shape and a machine-readable `code`, so a flow can branch on it without parsing English.

Source: https://postwire.io/docs/errors/ · Base URL: https://postwire.io · OpenAPI: https://postwire.io/openapi.json

## Error shape

```json
{
  "error": "not connected: tiktok. Connect it in Accounts, or post only to bluesky.",
  "code": "not_connected",
  "platforms": [
    "tiktok"
  ]
}
```

| Status | When |
|---|---|
| `400` | The request is wrong: a missing field, a past `run_at`, a network not connected (code `not_connected`), media required (`media_required`) or unreachable (`media_unreachable`). |
| `401` | No API key, or a key that does not exist or was revoked. |
| `402` | A feature the plan does not include (`plan_feature`), with the plan that does. |
| `403` | Email not verified, or a key that cannot publish. |
| `404` | Not found (`not_found`, `brand_not_found`). Ids that are not valid answer 404 too. |
| `409` | `ambiguous_brand` (say which brand), `duplicate_post` (see idempotency), or a scheduled post that is already publishing. |
| `413` | A file too large (`too_large`), or one that must be uploaded in parts (`use_multipart`). |
| `429` | Too many requests (`rate_limited`), or a plan's daily AI drafts used up. |
| `503` | Something on PostWire's side is not available (`auth_unavailable`, `ai_unavailable`, `webhooks_unavailable`). Retry. |

## Result codes per network

In `results[]` of `POST /api/post`, in a scheduled post's results and in `post.failed` webhooks. The list is open: new codes can appear, so treat an unknown code as a failure with the text in `error`.

| code | Means | What to do |
|---|---|---|
| `not_connected` | The network is not connected (in that brand). | Connect it, or leave it out of the request. |
| `ambiguous_brand` | The network is connected in several brands and no brand_id was sent (whole request, 409). | Send brand_id. |
| `media_required` | TikTok, YouTube and Instagram need a video or photos; a Reel needs a video. | Add video_url, photo_url or media. |
| `media_unreachable` | A media link could not be downloaded (private, expired, deleted, or a web page). Checked once before anything is sent. | Use a public direct link, or upload the file. |
| `bad_media` | The media list does not fit the network (too many items, a type it does not take). | Follow the error text. |
| `caption_is_json` | The text was a JSON object, not the words of the post. | Send the post's words as text. |
| `empty_text` | Nothing to publish. | Send text, a per_platform draft, or media. |
| `limit_reached` | The month's posts on the plan are used up. Comes with upgrade_url. | Wait for the 1st, or upgrade. |
| `networks_per_post` | held: true. On Free a post goes out to 2 networks; this one was written and not sent, and spent nothing. | Upgrade, or send it in another post. |
| `rate_limited` | The network asked to wait (retry_after seconds) and the post could not be queued for after it. | Post it again after retry_after. |
| `rate_limited_deferred` | ok: true, status scheduled. The network asked to wait longer than the request had; this network was queued for right after the wait. | Nothing. Follow schedule_id. |
| `timed_out` | status: unknown. The network did not answer before PostWire had to reply. | Check the network before posting again. |

A failed network also carries `what` and `fix`: the same plain-words explanation the failure emails use.

## Request rate limits

Over a limit the API answers 429 with a `Retry-After` header (seconds) and the same number in the body:

```json
{
  "error": "too many requests — please slow down and retry in 60 seconds",
  "code": "rate_limited",
  "retry_after": 60
}
```

| Endpoint | Limit |
|---|---|
| `POST /api/post` | 60 a minute per account |
| `POST /api/generate` | 8 a minute per account, plus the plan's AI drafts a day |
| `POST /api/week` | 3 an hour per account (each counts as one AI draft) |
| `POST /api/media/upload-url` | 60 an hour per account |
| `POST /api/media/upload-link` | 30 an hour per account |
| `POST /api/keys` | 20 a day per account |
| `POST /api/webhooks` | 20 a day per account; POST …/test 30 an hour |
| `GET /api/connect/{platform}/health` | 240 an hour per account |
| `POST /api/signup` | 5 an hour per IP address, 3 a day per email |

Plan limits (posts a month, AI drafts a day, brands, networks per post, scheduled posts at a time) are not rate limits: they answer with their own code and an upgrade link, and `GET /api/usage` shows each one. See [plans and limits](/docs/#plans).

## Network rate limits

Networks have their own limits. Telegram allows about one message a second per chat and 20 a minute in a group. PostWire spaces posts to the same chat, waits out a Telegram 429 when the request has time for it, and otherwise queues that network for right after the wait: its result is `ok: true`, `status: "scheduled"`, `deferred: true`, code `rate_limited_deferred`, with the `schedule_id` and `run_at`. Nothing is lost, and the queued post is counted and sent to your webhooks when it goes out.

## Idempotency: retries without duplicates

A publish cannot be undone, and n8n, Make, Zapier and agents all retry. So `POST /api/post` refuses the same post twice:

- Send `idempotency_key` (any string up to 200 characters). The same key on the same account within **24 hours** answers 409 `duplicate_post` and publishes nothing.
- Without a key, an identical request (same networks, text, drafts, media and brand) within **2 minutes** answers 409 `duplicate_post`.
- When nothing went out (every network failed), the lock is released at once, so the retry that fixes the problem is not refused.
- The key travels in the `post.published` and `post.failed` webhooks, so you can match events to your request.

**Request**

```json
{
  "platforms": [
    "linkedin"
  ],
  "text": "Doors open at 7.",
  "idempotency_key": "order-1842-announce"
}
```

**The same request again**

```json
{
  "error": "That idempotency_key was already used for a post on this account in the last 24 hours. Nothing was published again.",
  "code": "duplicate_post"
}
```
