# Limits, statuses and errors

Every number on this page is also in `get_account` (`limits`): post length (`rich_text_chars`, `markdown_text_chars`, `markdown_caption_chars`), buttons, field caps, quotas and media timings. Read them from there in code.

## Access

- A **paid** PostFrog Pro plan. Free and the Pro trial get HTTP 403 `pro_required`. When Pro ends, the key stays but answers `pro_required` until the author renews.
- One key per author. If the author re-issues it in the bot, the old key stops working at once (HTTP 401 `invalid_key`).
- While the API is rolled out step by step, an account that is not open yet gets HTTP 403 `api_not_available`.
- MCP: if the client reports OAuth, "Dynamic Client Registration" or "needs authentication", the `Authorization: Bearer` header with the key did not reach the server. PostFrog has no OAuth: add the header (Claude.ai without a Request headers field: use Claude Code or Cursor for now).

## Quotas

| What | Limit |
|---|---|
| Items per `send_posts` call | 10 |
| Items per day per author | 30 (UTC day; cancelled items do not count) |
| Items generated at the same time per author | 2 (the rest wait in the queue) |
| Requests per minute per key | 60, REST and MCP together; every MCP message is a request |
| Request body | 256 KB of JSON |
| `text` | 32,768 characters |
| `instructions` | 4,000 characters |
| `material[]` | 5 entries, 20,000 characters of text each, `source` up to 200 |
| `media[]` | 10 entries, see `media` for sizes |
| `preset` | 64 characters |
| URLs | 2,048 characters |
| `external_id` | 200 characters |
| `idempotency_key` | 200 printable ASCII characters, no spaces |
| Post length | Rich 31,268; Markdown 4,096, with media 1,024 (see `formatting`) |
| `threads` | 12 posts in the chain, 500 characters each (Unicode code points: an emoji is one), 10 media each, https links only (see `media`) |
| Buttons | 8 per row, 40 per post; label 40 characters (in `buttons`: longer is cut, over 200 is an error), URL 2,048, copy text 256 (see `buttons`) |

## Posts and the AI budget

- **Every item is one post** of the author's monthly allowance, `exact` included. Pro includes 150+ posts a month.
- `get_account.posts_left`: `null` means there is no fixed number to show (normal for Pro), `0` means no posts are left this month.
- `get_account.budget_ok`: `false` means the monthly PostFrog AI budget is used up. Then only `exact` items with `on_invalid: "fail"` are accepted: they need no AI.
- Checks happen when you send (HTTP 422 for the whole batch) and again before each item. If the allowance runs out in the middle of a batch, the remaining items become `skipped_limit` and the author gets one message about it.

## Statuses

Item: `queued` → `generating` → `ready` → `delivered`, or a final `failed`, `skipped_limit`, `cancelled`.

| Item status | Meaning |
|---|---|
| `queued` | Waiting for its turn. Can be cancelled. |
| `generating` | Being made right now. |
| `ready` | Made, waiting to be delivered in batch order (or for the author to finish chatting). |
| `delivered` | In the author's chat. `post.text` is the final text. Done. |
| `failed` | Could not be made; see `error` and `warnings`. |
| `skipped_limit` | Not made: posts or AI budget ran out. |
| `cancelled` | Cancelled before it started. |

Batch: `queued` (nothing started) → `running` → `done` (every item is final and none was cancelled) or `cancelled` (every item is final and at least one was cancelled: by `cancel_batch`, by the author's stop button in the bot, or because the author blocked the bot). **Stop polling on `done` or `cancelled`.**

`get_batch` returns, per item: `status`, `post_id`, `post` (`{ "format", "text" }` or `null` until the post exists), `warnings` (`[{ "code", "message" }]`), `error`, timestamps, `threads` (the current Threads version of the post, or `null`) and `threads_publication` (`{ "status", "url" }` once the author published it to Threads, else `null`). Items keep the order of your request (`position` from 0).

## Errors of the post check

`validate_post` returns them in `errors`; `send_posts` rejects an `exact` item (with `on_invalid: "fail"`) that has any of them, as HTTP 400 with the code at the start of the message.

| Code | Meaning | Fix |
|---|---|---|
| `empty_text` | The post text is empty | Send the post text |
| `too_long` | Over the length limit of the format (see `formatting`) | Shorten it, or switch Markdown to Rich |
| `invalid_post` | The post cannot be rendered in this format | Read the message, fix the markup |
| `media_index_out_of_range` | `%%MEDIA:n%%` points to a media the item does not have (indexes start at 0) | Fix the index or add the media |
| `invalid_button` | A button in `buttons` is broken: empty label after cleaning, bad URL, both or none of `url` and `copy` | Fix that button (the message names `buttons.row.column`) |
| `too_many_buttons` | More than 40 buttons in total: in the text plus the `buttons` field | Remove some |
| `reserved_marker` | The text contains `%%POST%%` or `%%END%%`, markers reserved by PostFrog | Remove them |

## Errors of the Threads version

Only for accounts with `threads_enabled: true` in `get_account`. `send_posts` rejects an item with a broken `threads` as HTTP 400, the code at the start of the message and the path `items.N.threads…`. `validate_post` with `threads` returns `threads: { "ok", "errors": [{ "code", "message", "item"? }] }`, where `item` is the number of the post in the chain, from 1 (no `item`: the whole chain); its top `ok` is `false` when the chain has errors.

| Code | Meaning | Fix |
|---|---|---|
| `threads_disabled` | The Threads version is not enabled for this account | Send the item without `threads` |
| `threads_empty_post` | The chain has no posts, or a post has no text | Remove the empty post or write its text |
| `threads_too_long` | A post is over 500 characters | Split it into two posts of the chain |
| `threads_too_many_posts` | More than 12 posts in the chain | Merge or drop some |
| `threads_too_many_media` | More than 10 media in one post | Move some to the next post |
| `threads_not_https` | A media URL is not https | Use a public https link |

## Warnings

Warnings never block a post. Read them and improve your next request. Codes from the post check (`validate_post`, `exact`):

| Code | Meaning |
|---|---|
| `unknown_tag` | An HTML tag that does not render was found |
| `button_fixed` | A button tag in the text was repaired or removed |
| `button_label_truncated` | A label longer than 40 characters was cut |
| `copy_button_moved` | A copy button asked for `below_post` was put at the end of the text |
| `long_dash` | Em dash or en dash in the text |
| `buttons_below_post`, `inline_buttons_as_links`, `markdown_table`, `markdown_html_tag`, `media_placeholder_ignored` | Markdown mode cannot show this (see `formatting`) |

Media warnings are listed in `media`. Codes from generation (`get_batch`, per item):

| Code | Meaning |
|---|---|
| `exact_formatted` | An `exact` post failed the check and `on_invalid: "format"` sent it to PostFrog AI |
| `preset_not_found` | No preset with that name: the post was made without it |
| `preset_ignored` | Presets do not change a ready post: ignored in mode `exact` |
| `material_fetch_failed` | A `material` URL could not be downloaded: the item continued without it |
| `material_truncated` | A `material` text was too long and was cut |
| `material_ignored` | `material` is not used in mode `exact` |
| `button_rejected` | A button from `buttons` did not pass the check and was dropped |
| `buttons_skipped` | Buttons from `buttons` were not added: the post with them would fail the post check (see the message) |
| `button_untrusted_demoted` | A link or code from untrusted `material` stayed plain text instead of a button: put the URL in `buttons` if the author wants it |
| `model_note` | A short note from PostFrog AI about the item (no chat message is sent to the author) |
| `interrupted` | The bot restarted during generation: the item was retried |

An item can also end as `failed`. Its `error` is a code, alone or followed by a colon and details (`model_error: …`): compare the part before the first `:`.

| `error` code | Meaning | What to do |
|---|---|---|
| `no_post` | No post came out of generation | Send the item again, maybe with clearer `text` or `instructions` |
| `model_error` | PostFrog AI failed for this item | Send it again later |
| `invalid_post` | The `exact` post failed the post check when it was processed, with the media that actually arrived (details list the errors) | Fix the post (`validate_post`) and send it again |
| `nothing_to_write` | `write` had nothing left to use: no text, and every material and media failed | Check the URLs (see `warnings`), pass text in `material[].text` |
| `delivery_failed` | The post was made, but the bot could not send it to the chat after several tries | Tell the user; sending it again makes a new post |
| `interrupted` | Generation was interrupted twice by bot restarts | Send it again |
| `bot_blocked` | The author blocked the bot; other queued items are cancelled | Stop and tell the user |
| `banned` | The account was blocked | Stop |
| `no_user` | The account no longer exists | Stop |
| `internal_error` | A failure on PostFrog's side | Send the item again later |

When only the monthly AI budget runs out, items that need PostFrog AI end as `skipped_limit`, while `exact` items (with `on_invalid: "fail"`) are still made. Treat any unknown code as information and show it to the user if it matters.

## Errors

REST: HTTP status + `{ "error": { "code", "message", "details"? } }`. MCP: the same JSON in a tool result marked as an error, including arguments that do not match the tool's input schema (`invalid_request` with `details.issues`). If the key itself is missing or wrong, or the account has no access, the MCP endpoint answers with the HTTP status (401, 403, 429) and a JSON-RPC error whose `data` is the same `{ "code", "message" }`, before any tool runs.

| HTTP | `code` | What to do |
|---|---|---|
| 400 | `invalid_request` | Fix the request. `details.issues` lists `{ path, message }`, e.g. `items.0.text` with `too_long: …`. For `exact` errors the code is at the start of the message. Nothing was created. |
| 401 | `invalid_key` | The key is missing, wrong or re-issued. Ask the author for the current key. Do not retry. |
| 403 | `pro_required` | Tell the author that the API needs paid Pro. Do not retry. |
| 403 | `api_not_available` | The API is being opened step by step and not for this account yet. Tell the author. Do not retry. |
| 403 | `banned` | The account is blocked. Stop. |
| 404 | `not_found` | Unknown batch id (or another account's batch). Check the id. |
| 409 | `idempotency_conflict` | This idempotency key was used with a different body. Use a new key for a new batch. `details.batch_id` is the earlier batch. |
| 413 | `payload_too_large` | Body over 256 KB. Split the batch. |
| 422 | `limit_exceeded` | Quota. `details`: `posts_left`, `items_left_today`, `budget_ok`. Send at most `items_left_today` items, wait for the next UTC day, or, if only `budget_ok` is false, send `exact` items only. |
| 429 | `rate_limited` | Too many requests. Wait the `Retry-After` seconds (also in the message), then retry. |
| 500 | `internal_error` | Retry once later with the same idempotency key. |

## Idempotency: safe retries

Pass `idempotency_key` to `send_posts` (REST: the `Idempotency-Key` header), for example a UUID or `digest-<date>-<slot>`. Sending the same key with the same body again returns the **same batch** (nothing is duplicated, quotas are not charged twice). The same key with a different body is `idempotency_conflict`. Always use a key when a retry is possible (timeouts, network errors).