# PostFrog API: overview

PostFrog is a Telegram bot that helps channel authors make posts. Through this API (and the MCP server on top of it) an agent sends **materials**, and PostFrog turns each one into a **post draft** for the author who owns the API key.

## Where the posts go

- Posts are **not returned to you** as the result of a call. Each post arrives as a draft in the author's private chat with the PostFrog bot, as a normal message with a preview and buttons.
- Drafts arrive **in the order of your batch**. If the author is chatting with the bot right now, finished posts wait for about 2 minutes of silence so they do not break the conversation.
- After delivery the draft is an ordinary PostFrog post: the author edits it in free text ("make it shorter", "add a button"), changes media and buttons, and **publishes it to their channel with one tap**.
- You can read the final text of each post with `get_batch` (field `post.text`, same markup as the input of `exact`).

## What you cannot do

- Publish to a channel, schedule a post or edit a published one. Only the author does that, in the bot.
- Send anything to other people. The bot writes only to the owner of the key.
- Use the API on Free or on the Pro trial: a **paid** Pro plan is required (HTTP 403 `pro_required` otherwise).

## Workflow

1. `get_account` - plan, posts left, today's quota, the author's default format and language, all limits.
2. (media from a local file) `create_upload`, then upload the file with the `curl` command it returns.
3. `validate_post` - for `exact` posts: a free dry run of the same checks `send_posts` runs. Nothing is saved.
4. `send_posts` - one batch of 1 to 10 items. The response has `batch_id` and one `item_id` per item with status `queued`.
5. `get_batch` - statuses, `warnings`, errors and the final post text. Poll it no more than a few times a minute; generation of one item takes from seconds (`exact`) to a minute or two (`write` with links and media). Items may also wait in the queue (other items of the author, the author chatting with the bot). You do not have to wait for delivery: once `send_posts` succeeded, it is fine to tell the user the drafts will arrive in their PostFrog chat and stop.
6. `cancel_batch` - cancels items that have not started yet.

## Tools and REST endpoints

| MCP tool | REST | What |
|---|---|---|
| `get_account` | `GET https://bot.postfrog.site/api/v1/me` | account, quotas, limits |
| `get_guide` | `GET https://bot.postfrog.site/api/v1/guide?topic=` | this guide |
| `validate_post` | `POST https://bot.postfrog.site/api/v1/validate` | check an `exact` post |
| `create_upload` | `POST https://bot.postfrog.site/api/v1/uploads` (multipart) | upload a photo or video file |
| `send_posts` | `POST https://bot.postfrog.site/api/v1/batches` (+ `Idempotency-Key` header) | send a batch |
| `get_batch` | `GET https://bot.postfrog.site/api/v1/batches/{id}` | batch status and results |
| `cancel_batch` | `DELETE https://bot.postfrog.site/api/v1/batches/{id}` | cancel queued items |

MCP server: `https://bot.postfrog.site/mcp` (Streamable HTTP). Every request carries `Authorization: Bearer pf_live_…` - the author's key from the bot (Settings, "API & MCP"). Never print or log the key.

## Guide topics

- `modes` - `exact`, `format`, `write`: which one to pick. **If you can write, write the post yourself and send `exact`.**
- `formatting` - PostFrog Rich Markdown (the markup of `exact` posts and of the results), Markdown mode, a cheat sheet.
- `buttons` - link and copy buttons: the `buttons` field and `<tg-button>` tags.
- `media` - photos and videos: URLs, uploads, placement, warnings.
- `limits` - quotas, statuses, error codes and what to do on each.
- `examples` - complete requests from task to JSON.

---

# Modes: exact, format, write

Every item of a batch has a `mode`. It says what PostFrog should do with your text.

| Mode | What PostFrog does | PostFrog AI | Required |
|---|---|---|---|
| `exact` | Takes `text` as the finished post in PostFrog markup: checks it, cleans buttons, renders it. Does not change a word. | no | `text` |
| `format` | Keeps the author's words and fixes the layout: paragraphs, title, emphasis, lists, buttons from links. | yes | `text` |
| `write` | Writes a post from scratch: from notes, `material` (pages, other posts) and media. | yes | at least one of `text`, `material`, `media` |

## Recommendation: write it yourself and send `exact`

If you are a capable writer (you are), **write and format the post yourself in Rich Markdown and send it with `mode: "exact"`**:

- It is predictable: the author gets exactly what you wrote, with the buttons and media where you put them.
- It does not spend the author's PostFrog AI budget. That budget stays for the author's own edits inside the bot. When the budget is used up, only `exact` items are accepted at all.
- It is fast: no generation, only checks and rendering.
- You can check it for free first with `validate_post` and fix every error before sending.
- The author can still edit the post in the bot afterwards in free text: the markup is the same one PostFrog's own editor uses.

Use `format` when you have the author's text and want PostFrog's own house style without writing markup yourself. Use `write` when you only have raw material and want PostFrog to do the writing (for example a cheap pipeline that does not run a strong model itself).

An `exact` item still counts as one post of the author's monthly allowance, like any other item.

## `on_invalid` (only for `exact`)

- `"fail"` (default): a post with errors is rejected. In `send_posts` the whole batch is rejected with HTTP 400 and the exact errors per item (`items.N.text`, `items.N.buttons`), nothing is created and nothing is spent. Fix and send again.
- `"format"`: a post with errors is accepted, and PostFrog's AI repairs it as in `format` mode. That is an AI run: it needs the AI budget (`budget_ok: true` in `get_account`).

Soft problems are never errors: an unknown HTML tag, a fixed or shortened button, a long dash. They come back as `warnings` and the post goes through. See `limits` for the codes.

## Fields that shape the result

| Field | For | Notes |
|---|---|---|
| `text` | all | `exact`: the post. `format`: the author's text to keep. `write`: notes or a brief. Up to 32,768 characters. |
| `instructions` | `format`, `write` | Wishes for the result: tone, length, focus, structure ("calm tone, under 800 characters, stress the price"). Up to 4,000 characters. Ignored by `exact`, which is never rewritten. |
| `material[]` | `format`, `write` | Raw sources: `{ "url": … }` and/or `{ "text": …, "source": "Threads @someone" }`. Up to 5 per item, 20,000 characters of text each. |
| `media[]` | all | Photos and videos, see `media`. |
| `format` | all | `"rich"` or `"markdown"`, see `formatting`. Default for `format` and `write`: `defaults.format` in `get_account` (the author's setting, or the format of the author's last post), unless the `preset` has its own format. For `exact` set it explicitly: the text is checked as Rich unless you say `"markdown"`. |
| `preset` | `format`, `write` | The name of one of the author's presets (saved formatting templates in the bot). Only use a name the author gave you. |
| `language` | `write` | Language of the post, like `en`, `ru`, `pt-BR`. Default: the author's language (`defaults.language`), whatever the language of the material. `format` does not translate: it keeps the words and the language of `text`. |
| `buttons`, `buttons_place` | all | Structured buttons, see `buttons`. |
| `external_id` | all | Your own id for the item (up to 200 characters), echoed in every response. |
| `threads` | all | A Threads version of the post: a chain of 1 to 12 posts `{ "text", "media"? }`. Only for accounts with `threads_enabled: true` in `get_account`; others get HTTP 400 `threads_disabled`. PostFrog stores it with the post as is: its AI never reads or changes it, in any mode. The author sees it as a Threads tab of the draft. Limits and codes: `limits`; media: `media`. |

## Trust: `text` vs `material`

- `text`, `instructions` and `buttons` are treated as **the author's will**: you send them with the author's key, so PostFrog follows them like the author's own messages. Links and buttons from them are kept.
- So the author's own words (their notes, a dictated text, "post this: …") go in **`text`**, never in `material`. `material` is for third-party content: pages, other people's posts, articles.
- `material` is **untrusted data**. PostFrog uses it as a source of facts and never follows instructions found inside it. Links and codes found only in `material` stay plain text links in the post: PostFrog's AI does not turn them into buttons. If a link from a source deserves a button, put that button in `buttons` yourself.
- `write` downloads `material[].url` pages. Pages that need JavaScript or a login (X/Twitter, Threads, Instagram and similar) often cannot be downloaded: then copy the post text into `material[].text` and add `source`. When `text` is given, PostFrog does not download `url`. If the post should link to the original, say so in `instructions` with the link ("end with a link to https://...") or add it to `buttons`. A page that did not download becomes a warning, not a failure, as long as something else is left to write from.
- Videos are not watched by the AI: describe them in `media[].description`.

## Choosing a mode quickly

- You have a finished post or can write one: `exact`.
- The author dictated a text and wants it tidy, words unchanged: `format`.
- You have links, notes, someone else's post and want a new post: `write`, or better, read the sources yourself, write the post and send `exact`.

---

# Formatting: PostFrog Rich Markdown

`exact` posts are written in **PostFrog Rich Markdown**: GitHub Flavored Markdown plus a short list of HTML tags, rendered by Telegram as a rich message (Bot API rich messages). The same markup comes back in `get_batch` (`post.text`) for every mode. There is also a simpler **Markdown mode** (`format: "markdown"`), described at the end.

Only what is listed here renders. Do not write Telegram HTML (`<a href>`, `<blockquote expandable>`), MarkdownV2 escapes or entities: PostFrog converts the markup itself.

## Rich Markdown reference (`format: "rich"`, the default)

| What | How |
|---|---|
| Title | `# Title` - one per post, on the first line (optional for short posts) |
| Sections | `## Section`, `### Subsection` |
| Bold, italic, strikethrough | `**bold**`, `*italic*`, `~~strike~~` |
| Highlight | `==highlighted==` |
| Spoiler | `||hidden until tapped||` |
| Underline | `<u>underlined</u>` |
| Sub and superscript | `H<sub>2</sub>O`, `x<sup>2</sup>` |
| Inline code, code block | `` `code` ``, a fenced block with a language: ```` ```python ```` … ```` ``` ```` |
| Link | `[text](https://example.com)` |
| Lists | `- item`, `1. item`, checkbox `- [ ] item` |
| Table | GFM table, alignment with `|:---|---:|` |
| Quote | `> quoted text` |
| Pull quote with author | `<aside>Quote text<cite>Author</cite></aside>` (no markup inside) |
| Divider | `---` on its own line, between big sections |
| Collapsible block | `<details><summary>Title</summary>Hidden text</details>` |
| Footnote | `text[^1]` and at the end `[^1]: note` |
| Formula | `$inline$` or `$$block$$` |
| Photo or video | a line `%%MEDIA:0%%` (see `media`) |
| Collage | a line `%%MEDIA:0,1,2%%` - three or more media in one line |
| Buttons in the text | `<tg-button …>` and `<tg-button-row>` (see `buttons`) |
| Author's custom emoji | `🔥{ce:ID}`: a Unicode emoji followed by `{ce:ID}`. Only IDs the author gave you; otherwise plain Unicode emoji |

Also accepted as plain HTML: `<b>`, `<i>`, `<s>`, `<code>`, `<pre>`, `<br>`, `<blockquote>`. Prefer the Markdown forms above. Any other tag produces the warning `unknown_tag` and does not render.

### Rules that matter

1. **Blank line between paragraphs.** A single line break inside a paragraph becomes a space, so lines without a blank line between them merge into one paragraph. Lists, quotes and tables keep their own line breaks.
2. **Markdown is not parsed inside block HTML tags** except `<details>` (and media collages). Inside `<aside>`, `<blockquote>` or a `<tg-button-row>` write plain text.
3. Put block elements (`%%MEDIA%%` lines, button rows, `<details>`, `<aside>`, tables, `---`) on their own lines with blank lines around them.
4. **Length:** an `exact` Rich post may have up to **31,268 characters including markup**. Telegram's own limit is 32,768; PostFrog keeps the rest for media links and its footer. Longer is the error `too_long`.
5. Avoid the em dash and the en dash: they trigger the warning `long_dash` (PostFrog's house style). Use a hyphen with spaces, a colon or a comma.
6. Links you put in the post are kept as they are. A bare URL works, but a short phrase link `[Sign up](https://…)` or a button reads better.
7. No Telegram MarkdownV2 escaping: write `Price: 5.99!` and `well-known`, not `5\.99\!` or `well\-known`. Normal Markdown rules still apply (`*word*` is italic).

### A good Rich post

- A short title (`#`), paragraphs of 2-4 lines, `##` subheadings only in long posts.
- **Bold** for the key phrase or number of a paragraph (at most one per paragraph), ==highlight== for one or two main phrases of the whole post.
- Three or more items: a list. Comparisons and numbers: a table. The closing thought: a `>` quote or bold.
- Long optional parts (specs, sources, a FAQ) go into `<details>`.
- Markup should serve the text: keep it under roughly a third of the post.
- Buttons only where there is a reason: a real link, a promo code, a call to subscribe (see `buttons`).

## Markdown mode (`format: "markdown"`)

For authors whose channel uses classic Telegram formatting. Write **plain Markdown**; PostFrog converts it to Telegram MarkdownV2 and escapes it itself.

- Works: `**bold**`, `*italic*`, `__underline__`, `~~strike~~`, `||spoiler||`, `` `code` ``, ```` ``` ```` blocks, `> quote`, `[text](url)`, lists as lines starting with `•` or `1.`.
- Not available: headings (use a bold line as the title), tables (warning `markdown_table`), `<details>`, formulas, any HTML tag (warning `markdown_html_tag`).
- **Buttons:** rows of buttons go under the post as a keyboard (warning `buttons_below_post`); buttons inside a line become a link or `code` (warning `inline_buttons_as_links`).
- **Media** always sits on top of the post (as a caption post or an album); `%%MEDIA:n%%` lines are ignored (warning `media_placeholder_ignored`).
- **Length:** 4,096 characters, and only 1,024 when the post has media (it becomes a caption). Measured **after** conversion to MarkdownV2, which adds escape characters: leave some headroom. Over the limit is the error `too_long`; shorten it or switch to Rich.

Use Markdown mode only when the author's default is `markdown` (`defaults.format` in `get_account`) or the author asked for it. Otherwise use Rich.

## Cheat sheet: how to…

| Task | Rich Markdown |
|---|---|
| Title + lead | `# Title` blank line `Lead paragraph` |
| Photo as the cover | first line `%%MEDIA:0%%`, or no placeholder at all (media without a placeholder goes on top) |
| Photo after the second paragraph | a `%%MEDIA:1%%` line between the paragraphs, blank lines around |
| Three photos as a collage | a line `%%MEDIA:0,1,2%%` |
| Button to a site at the end | `buttons: [[{"text": "Open the site", "url": "https://…"}]]` |
| Button inside a sentence | `Sign up today <tg-button type="url" url="https://…">Sign up</tg-button>` |
| Promo code you can copy | `<tg-button type="copy_text" text="FROG20">Copy the promo code</tg-button>` |
| Hidden answer | `||the answer||` |
| Long details folded | `<details><summary>Specs</summary>…</details>` |
| Source line at the end | `Source: [Example News](https://…)` |
| Quote with author | `<aside>Ship it.<cite>Jane Doe</cite></aside>` |
| Checklist | `- [ ] Buy clay` per line |

---

# Buttons

Telegram rich posts can carry buttons **inside the text**: in a line of a paragraph or as a row of buttons between paragraphs. Posts support exactly two button types:

- **link** - opens a URL (`url`);
- **copy** - copies a text to the reader's clipboard (`copy` / `copy_text`): promo codes, wallet addresses, commands, e-mails, phone numbers. Telegram cannot copy a URL by tapping `code`, so use a copy button for that too.

Other Telegram button types (callback, web app, login and so on) are not available in posts and are removed.

There are two ways to add buttons. Both are trusted as the author's will and pass the same cleaning and limits.

## 1. The `buttons` field (recommended)

Structured rows of buttons, no markup to get wrong. Works in every mode, and PostFrog's AI never drops them.

```json
"buttons": [
  [ { "text": "Read the original", "url": "https://example.com/news" } ],
  [ { "text": "Copy the promo code", "copy": "FROG20", "style": "success" } ]
],
"buttons_place": "end"
```

- Each inner array is one row, up to 8 buttons. Up to 40 buttons per post in total.
- A button has `text` and exactly one of `url` or `copy`. Optional `style`: `"primary"` (blue), `"success"` (green), `"danger"` (red). No style = the default look; use color only when there is a reason.
- `buttons_place`:
  - `"end"` (default) - rows at the end of the post text;
  - `"start"` - rows at the top of the post (Rich only; in Markdown they go under the post);
  - `"below_post"` - link buttons go into the keyboard under the post instead of the text. Copy buttons cannot sit under a post: they are added as a row at the end of the text (warning `copy_button_moved`).
- In Markdown mode all rows end up under the post as a keyboard.
- With `exact`, the rows are inserted into your text right away: `validate_post` returns the result in `normalized_text`, and `buttons_below_post` lists the links that go under the post.

## 2. Tags inside an `exact` text

Use tags when a button belongs **inside a sentence** or between specific paragraphs.

Inline, at the end of (or inside) a paragraph line:

```
Registration is open until Friday. <tg-button type="url" url="https://example.com/join">Sign up</tg-button>

Promo code FROG20 gives a discount. <tg-button type="copy_text" text="FROG20">Copy the promo code</tg-button>
```

A row, as a block of its own:

```
Last paragraph of the post.

<tg-button-row>
<tg-button type="url" url="https://t.me/example_channel">Subscribe</tg-button>
<tg-button type="url" url="https://example.com" style="primary">Website</tg-button>
</tg-button-row>
```

Rules for tags:

- Attributes: `type` (`url` or `copy_text`), `url` (for `url`), `text` (the value to copy, for `copy_text`), optional `style` (`primary`, `success`, `danger`). A row may have `align="left|center|right"`.
- A row: the opening `<tg-button-row>` line, **one button per line**, the closing `</tg-button-row>` line, a blank line before and after, **no blank lines inside**. More than 8 buttons in a row are split into several rows.
- **`&` in a URL inside a tag is written `&amp;`**: `url="https://example.com/?a=1&amp;b=2"`. (In the `buttons` JSON field write a normal `&`.)
- Broken or unknown button tags are removed or turned into text, with a `button_fixed` warning. Check with `validate_post`.

## Labels

- Plain text only, up to 40 visible characters. Short verbs work best: "Sign up", "Open the site", "Copy the code".
- In the `buttons` field a longer label is cut, with the warning `button_label_truncated`; a label over 200 characters is an error (HTTP 400 `invalid_request`, path `items.N.buttons.row.column.text`).
- Inside a `<tg-button>` tag a longer label is cut **without** a warning. Check the label in `normalized_text` of `validate_post`.
- No Markdown (`**`, `_`, `~`, `=`, `|`, backticks), no links, no HTML, no `@username` or `#hashtag` in a label: these are stripped. A plain Unicode emoji is fine.
- Keep `@channel` mentions in the post text and give the button the `https://t.me/channel` link.

## Values

- `url`: `https://` or `http://`, also `tg://` links. A bare domain (`example.com`) becomes `https://example.com`, `@name` becomes `https://t.me/name`. Up to 2,048 characters.
- `copy`: 1 to 256 characters, exactly what the reader gets in the clipboard.

## Good practice

- A button replaces a bare URL: do not repeat the same full address as text next to it.
- Be restrained: one row at the end plus at most two or three buttons inside lines is plenty for a normal post.
- Links that appear only in untrusted `material` are not turned into buttons by PostFrog's AI. If you want one, add it to `buttons` yourself.
- The author can move, recolor or remove any button later in the bot.

---

# Media: photos and videos

Each item can carry up to 10 media in `media[]`. Only **photos and videos** are supported (no GIF animations, documents, audio or voice).

```json
"media": [
  { "type": "photo", "url": "https://example.com/cover.jpg" },
  { "type": "video", "upload_id": "upl_…", "description": "Short screen recording: the bot formats a voice note into a post" }
]
```

Each entry has `type` (`"photo"` or `"video"`), **exactly one** of `url` or `upload_id`, and an optional `description` (up to 1,000 characters).

## Two ways to pass a file

| Way | How | When |
|---|---|---|
| `url` | A public **https** link to the file itself (not to a page with the file). PostFrog downloads it when the item is processed. | The file is already online. The only way in clients without file access (Claude.ai, ChatGPT). |
| `upload_id` | Upload the file first: MCP `create_upload` gives a ready `curl` command for `POST https://bot.postfrog.site/api/v1/uploads`; the response has `upload_id`. | A local file and you can run shell commands (Claude Code, scripts, n8n). |

Upload details:

- `multipart/form-data` with a `type` field (`photo` or `video`) **before** the `file` field. Response HTTP 201: `{ "upload_id", "type", "bytes", "expires_at", "width"?, "height"? }`.
- An `upload_id` lives 24 hours, belongs to the key's account and can be reused in several items. The `type` in `media[]` must match the uploaded type.
- If `get_account` shows `media_uploads_enabled: false`, media are switched off on this server: send posts without media.
- Upload errors: 400 `invalid_request` (wrong format or form), 408 `request_timeout` (the body was too slow, see below), 413 `payload_too_large`, 422 `limit_exceeded` (daily quota: `details.upload_bytes_left_today`, `details.uploads_left_today`), 429 `rate_limited` (wait `Retry-After`), 502 `media_store_failed` (Telegram did not store the file: retry the upload later), 503 `media_disabled` (media are switched off on this server: send posts without media).

## Media of the Threads version

`threads[].media` (see `modes`) takes only public **https** links to the file itself: `{ "type": "photo" | "video", "url" }`, no `upload_id` and no `description`. PostFrog does not download or store these files: the link is handed over when the author publishes the Threads version, so it must stay reachable until then. These media do not count towards `media[]` and do not appear in the Telegram post.

## Formats and sizes

| Type | Formats | Max size |
|---|---|---|
| photo | JPEG, PNG, WebP | 10 MB |
| video | MP4 or MOV with H.264/HEVC video and AAC audio | 50 MB |

- The format is checked by the file's content, not by its name or Content-Type. WebM, GIF, HEIC, AVIF and SVG are rejected.
- Uploads and downloaded URLs together share a daily quota of 500 MB and 300 files per author (UTC day).

## Speed limits

Media storage is shared by all authors, so slow transfers are cut:

- All media of one item must be downloaded and stored within 120 seconds. Media still missing after that are skipped with `media_too_slow` (files from `upload_id` are not affected: they are already stored).
- A file that arrives slower than 32 KB/s for a while is cut with `media_too_slow`. Use a fast host (a CDN, not a home server).
- An upload body that stops for 60 seconds, or takes longer than 180 seconds to send, is answered with HTTP 408 `request_timeout`. Retry on a better connection.
- Many uploads in a row from one author are paced: the response simply comes a bit later.

## Placement in the post

- Media are numbered in the order of `media[]`, **starting at 0**. In a Rich post, a line `%%MEDIA:0%%` puts the first media at that spot.
- `%%MEDIA:0,1,2%%` - three or more media in one line become a **collage**. Two in one line are shown one after another.
- Use each index once, each placeholder on its own line with blank lines around it.
- Media without a placeholder go to the **top of the post** as a cover (three or more of them as a collage). So for a simple "photo on top" post you do not need any placeholder.
- In `exact` mode a placeholder pointing past the end of `media[]` is the error `media_index_out_of_range`. Pass `media_count` (or `media`) to `validate_post` to check it.
- In Markdown mode media always sit on top (a caption post, or an album for several) and placeholders are ignored. The text must then fit the caption limit, see `formatting`.

## What PostFrog's AI sees

In `format` and `write` the AI does **not watch videos**. Describe every video in `description` (what happens, who speaks, the key point) so the post can talk about it. A short description of photos helps too.

## When a file fails

A broken media entry never fails the whole item: it is skipped, the order of the others is kept, and the item gets a warning `{ "code", "message": "media[i]: …" }` where `i` is the index in your `media[]`:

| Code | Meaning | What to do |
|---|---|---|
| `media_fetch_failed` | The URL could not be downloaded: not https, private address, DNS error, HTTP error, timeout | Check that the link is a public https link to the file |
| `media_too_large` | Larger than the limit of its type | Compress or shorten it |
| `media_bad_type` | Not a supported format, or Telegram did not accept it as that type | Convert: JPEG for photos, MP4 (H.264 + AAC) for videos |
| `media_quota_exceeded` | The author's daily media quota (bytes or number of files) is used up | Send tomorrow (UTC) or without media |
| `media_too_slow` | The file came too slowly, or all media of the item took longer than 120 seconds | Host the file on a faster server, send fewer or smaller media, or upload it and use `upload_id` |
| `media_store_failed` | Telegram did not store the file | Retry later |
| `media_disabled` | Media are switched off on this server | Send without media |
| `upload_expired` | The `upload_id` is older than 24 hours | Upload again |
| `upload_not_found` | No such `upload_id` for this account | Use an id from your own upload |
| `media_too_many` | More media than allowed | Send at most 10 |

`send_posts` already rejects an `upload_id` that is unknown, expired, not finished or of another type (HTTP 400 with the path `items.N.media.M.upload_id`), so those warnings are rare.

---

# 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).

---

# Examples: from task to request

The JSON below is the argument of the MCP tool. Over REST the same `items` go in the body of `POST https://bot.postfrog.site/api/v1/batches`, and `idempotency_key` goes in the `Idempotency-Key` header.

## 1. A finished post with a link button and a promo code (`exact`)

Task: "Announce the pottery course, sign-up link https://example.com/join, promo code CLAY15, photo https://example.com/studio.jpg."

You write the post yourself, then check it:

`validate_post`
```json
{
  "format": "rich",
  "media_count": 1,
  "text": "# Pottery course starts on Monday\n\nSix evening lessons for beginners: from the first lump of clay to a glazed cup you can drink from.\n\n%%MEDIA:0%%\n\n- small groups\n- clay and kiln included\n- a cup of your own at the end\n\nPromo code **CLAY15** gives 15% off. <tg-button type=\"copy_text\" text=\"CLAY15\">Copy the promo code</tg-button>",
  "buttons": [[{ "text": "Sign up", "url": "https://example.com/join?utm_source=tg&utm_medium=post", "style": "success" }]]
}
```

The answer has `"ok": true`, maybe some `warnings`, and `normalized_text` with the sign-up row already at the end. Then send the same post:

`send_posts`
```json
{
  "idempotency_key": "pottery-launch-v1",
  "items": [
    {
      "mode": "exact",
      "format": "rich",
      "external_id": "pottery-launch",
      "text": "# Pottery course starts on Monday\n\nSix evening lessons for beginners: from the first lump of clay to a glazed cup you can drink from.\n\n%%MEDIA:0%%\n\n- small groups\n- clay and kiln included\n- a cup of your own at the end\n\nPromo code **CLAY15** gives 15% off. <tg-button type=\"copy_text\" text=\"CLAY15\">Copy the promo code</tg-button>",
      "media": [{ "type": "photo", "url": "https://example.com/studio.jpg" }],
      "buttons": [[{ "text": "Sign up", "url": "https://example.com/join?utm_source=tg&utm_medium=post", "style": "success" }]]
    }
  ]
}
```

Tell the user: the draft will arrive in their PostFrog chat, where they can edit and publish it.

## 2. "Tidy up my text" (`format`)

Task: the author dictated a long message and wants it as a neat post, words unchanged.

```json
{
  "items": [
    {
      "mode": "format",
      "text": "so we finally moved the studio. new address is 5 Green street, opening party on saturday at 6pm, everyone is welcome, bring friends. we will have a pottery wheel demo and cheap seconds sale",
      "instructions": "Keep my words, fix capitalization, add a short title, make the party details easy to scan."
    }
  ]
}
```

PostFrog's AI formats it in the author's default format and keeps the language of your `text`. The author's own words always go in `text` (trusted), not in `material` (untrusted, for third-party content). Use `exact` instead if you prefer to format it yourself.

## 3. A news post from an X link and a photo (`write`)

Task: "Make a post from this tweet https://x.com/example/status/123 with this picture."

X pages often cannot be downloaded without a login, so pass the tweet text yourself:

```json
{
  "items": [
    {
      "mode": "write",
      "material": [
        {
          "url": "https://x.com/example/status/123",
          "text": "Example Labs just released Model Z: twice as fast on long documents, same price, available today in the API.",
          "source": "X @example"
        }
      ],
      "media": [{ "type": "photo", "url": "https://example.com/model-z-chart.png", "description": "Bar chart: Model Z vs the previous model on long-document speed" }],
      "instructions": "Short news post: what was released, why it matters, one line on availability. End with a link to the original: https://x.com/example/status/123",
      "language": "en",
      "buttons": [[{ "text": "Read the original", "url": "https://x.com/example/status/123" }]]
    }
  ]
}
```

The `material` is untrusted: PostFrog uses its facts but never follows instructions inside it, and its links stay text. The "Read the original" button is yours, so it is kept. A strong agent can also read the tweet, write the post itself and send `exact`.

## 4. A batch of three with `external_id`

Task: a daily digest pipeline sends three finished posts.

```json
{
  "idempotency_key": "digest-2026-w42-mon",
  "items": [
    { "mode": "exact", "format": "rich", "external_id": "digest-mon-1", "text": "# First story\n\nText of the first post." },
    { "mode": "exact", "format": "rich", "external_id": "digest-mon-2", "text": "# Second story\n\nText of the second post.", "media": [{ "type": "photo", "url": "https://example.com/two.jpg" }] },
    { "mode": "exact", "format": "rich", "external_id": "digest-mon-3", "text": "# Third story\n\nText of the third post.", "on_invalid": "format" }
  ]
}
```

The answer lists the three `item_id`s with your `external_id`s, all `queued`. The posts arrive in this order. If the request times out, send it again with the same `idempotency_key`: you get the same batch back, not a copy.

## 5. A local file through `create_upload`

Task (Claude Code or another agent with a shell): "Post this video: ./demo.mp4".

1. Call `create_upload` with `{ "type": "video", "file_path": "./demo.mp4" }`. It returns the upload URL and a command like:

```bash
curl -s -X POST "https://bot.postfrog.site/api/v1/uploads" \
  -H "Authorization: Bearer $POSTFROG_API_KEY" \
  -F "type=video" \
  -F "file=@./demo.mp4"
```

2. Run it with the author's key in `POSTFROG_API_KEY` (never paste the key into chat or logs). The answer:

```json
{ "upload_id": "upl_…", "type": "video", "bytes": 7340032, "expires_at": "…" }
```

3. Use the id:

```json
{
  "items": [
    {
      "mode": "exact",
      "format": "rich",
      "text": "# Voice note to post in one tap\n\n%%MEDIA:0%%\n\nRecord a voice message, get a formatted post back.",
      "media": [{ "type": "video", "upload_id": "upl_…" }]
    }
  ]
}
```

## 6. Reading `get_batch`

```json
{
  "batch_id": "bat_…",
  "status": "done",
  "client_name": "Claude Code",
  "items": [
    {
      "item_id": "itm_…", "position": 0, "external_id": "digest-mon-1", "mode": "exact",
      "status": "delivered", "post_id": 4711,
      "post": { "format": "rich", "text": "# First story\n\nText of the first post." },
      "warnings": [], "error": null
    },
    {
      "item_id": "itm_…", "position": 1, "external_id": "digest-mon-2", "mode": "exact",
      "status": "delivered", "post_id": 4712,
      "post": { "format": "rich", "text": "# Second story\n\nText of the second post." },
      "warnings": [{ "code": "media_fetch_failed", "message": "media[0]: …" }], "error": null
    },
    {
      "item_id": "itm_…", "position": 2, "external_id": "digest-mon-3", "mode": "exact",
      "status": "skipped_limit", "post_id": null, "post": null, "warnings": [], "error": null
    }
  ]
}
```

How to read it:

- Item 0 is in the author's chat as sent.
- Item 1 arrived **without its photo**: the URL did not download. Tell the user, and next time use a direct https link to the image or `create_upload`.
- Item 2 was skipped: the author ran out of posts or AI budget (check `get_account`). Do not resend it today unless `posts_left` and `budget_ok` allow it.
- When every item is `delivered`, `failed`, `skipped_limit` or `cancelled`, the batch is `done`, or `cancelled` if any item was cancelled: stop polling on either.

## 7. A post with a Threads version (`exact` + `threads`)

Only when `get_account` shows `threads_enabled: true`. The Telegram post goes in `text` as usual; the Threads version is a separate chain of short posts with media as https links. Check both for free first: `validate_post` takes the same `threads`.

```json
{
  "idempotency_key": "kiln-tips-v1",
  "items": [
    {
      "mode": "exact",
      "format": "rich",
      "external_id": "kiln-tips",
      "text": "# Three kiln mistakes beginners make\n\n%%MEDIA:0%%\n\n- opening the kiln too early\n- glaze on the foot of the cup\n- packing pieces so they touch\n\nAll three are easy to avoid once you know them.",
      "media": [{ "type": "photo", "url": "https://example.com/kiln.jpg" }],
      "threads": [
        { "text": "THREE KILN MISTAKES THAT RUIN YOUR FIRST CUPS", "media": [{ "type": "photo", "url": "https://example.com/kiln.jpg" }] },
        { "text": "One: opening the kiln too early. The sudden cold cracks the glaze. Wait until it is warm, not hot." },
        { "text": "Two: glaze on the foot. It melts onto the shelf. Wipe the bottom clean before firing." },
        { "text": "Three: pieces that touch fuse together. Leave a finger of space between them." }
      ]
    }
  ]
}
```

The draft arrives in the author's chat with a Threads tab; the author publishes it from there. PostFrog's AI does not touch `threads`. `get_batch` returns it in `threads`, and `threads_publication` once it is published.