# Vibe: every help file in one file
Built 2026-11-08 from 11 files; each starts with a comment naming it and its live address.
# Connect this agent to Vibe: a guide for AI agents
**Document version: 2026-11-03.**
The person who gave you this file wants their AI app connected to their
Vibe account (Vibe Pages and Vibe Inbox, on vibecontrol.co.il). Your job:
walk them through it, check that it works, and stop there. The live copy
of this file is at https://www.vibecontrol.co.il/user_manuals/connect_vibe.md
(add a changing `?v=` value to skip the cache); if you can fetch it and its
version line is newer than this one, follow the live copy.
## How to talk to the person
- They are not technical. Use their language (most write Hebrew: answer in
simple Hebrew), short sentences, everyday words.
- **One step at a time.** Give one step, wait until they say it is done or
show you a screenshot, then give the next. Never a list of ten steps.
- If their screen looks different from what you expect (menu names change
between app versions), ask them to describe it or send a screenshot, and
guide from what they see.
- In this guide you only connect and check. Do not create, change, upload,
publish or send anything in Vibe.
## Safety rules (always)
- **Never ask for, accept or type a password, the email sign-in code, an API
key or a token.** The sign-in happens in the person's own browser, on the
Vibe screen. If they paste a code or a key into the chat anyway, tell them
not to share it and to continue in the browser.
- Never choose "No authentication" or any setting that skips the sign-in.
- Do not delete or replace other MCP servers or settings in their app.
## The one fact that matters
Connecting is one address plus a sign-in. The address is:
```
https://www.vibecontrol.co.il/mcp
```
No key, no header, nothing to install. Everything below is where to paste
that address in each app.
## Step 1. Already connected?
If you already have Vibe tools (for example `list_accounts`), skip to
"Step 4. The check".
## Step 2. Which app are you in?
You usually know which app you run in. If you are not sure, ask the person
which app they use. Then follow only that app's part.
### Codex (the app)
1. Settings → MCP servers → Add server. (In some versions MCP sits under
Plugins.)
2. Type: **Streamable HTTP**. Name: `Vibe`. URL: the address above. Leave
every other field empty.
3. Save, then click **Authenticate**. If it offers Restart, restart first.
### Claude (the desktop app or claude.ai)
1. Claude's Settings → **Connectors**. (In some versions under Customize.)
2. **Add custom connector**. Name: `Vibe`. URL: the address above.
3. Click **Connect**.
A connector added once appears by itself in the app's **Code** tab (and in
Claude Code in a terminal) for the same Claude account. Work with local
files happens in the Code tab, with the business folder open. Do not tell
the person that a `.mcp.json` file in a folder is enough in the Claude app:
in the app the connection is the connector.
### Claude Code (in a terminal)
If you are Claude Code, you may add the server yourself. Ask the person
first, then run, in their business folder:
```sh
claude mcp add --transport http vibe https://www.vibecontrol.co.il/mcp
```
(Add `--scope project` to keep it in this folder's `.mcp.json`.) If a
`vibe` server already exists, do not add a second one. Then ask the person
to type `/mcp`, choose **vibe** and **Authenticate**.
### ChatGPT
1. Open **Plugins** in ChatGPT (https://chatgpt.com/plugins), click the plus
and choose **Add custom MCP server**.
2. Name: `Vibe`. Server URL: the address above. Authentication: **OAuth**.
Leave the optional client fields empty.
3. Create it, then click **Connect**.
4. In a new chat, type `@` and choose Vibe.
ChatGPT in the browser does not open the person's local folder. That is
fine for connecting and for most Vibe work; for work with local files, the
Codex app, the Claude app's Code tab or Claude Code are the way.
## Step 3. The sign-in in the browser
After Authenticate or Connect, the browser opens the Vibe sign-in. The
person signs in as usual, with the code that arrives by email (the code
stays in the browser, never in the chat). Then a screen asks:
- **Accounts:** all their accounts (including ones added later), or only
some.
- **What the agent may do:** דפי נחיתה (landing pages), דיוור (mailing), or
both.
They click **"אישור וחיבור"** and the browser sends them back to the app.
Sending mail and paying always stay with the person, whatever they pick.
If the person has no Vibe account yet, they open one first at
https://www.vibecontrol.co.il/accounts/new.
## Step 4. The check
Call `list_accounts`. Tell the person, in their language, which accounts
you are connected to and what you may do in each. That is the proof the
connection works; an answer that only explains what MCP is, is not.
If the tools do not show up yet: the app may need a new chat, or a restart
of the app. Then try again.
When several accounts are connected, every later task names the account:
ask which one when it is not clear.
## If something does not work
- **Tools do not appear:** check the address is exact, with `https://` and
`www`. Restart the app and open a new chat.
- **The browser did not open:** click Authenticate (or Connect) again.
- **"Authentication required" or "expired":** click Authenticate, Reconnect
or Connect and sign in again. This happens after a disconnect, or after
90 days without use.
- **The wanted account is missing:** in the consent screen they picked
other accounts. Disconnect (below) and connect again.
- **No place to add an MCP server or connector:** a work or school account
may block it. The person asks their account admin; do not change security
settings.
## Disconnecting
The person can disconnect any time: in the Vibe admin, API → **"החיבורים
שלי"**. One "נתק" stops the agent at once, on every computer.
## After connecting: what to read next
This file only connects. For the work itself, read the knowledge file of
the product before you start:
- Landing pages, lists, signup forms, campaigns and message series:
https://www.vibecontrol.co.il/user_manuals/vibe_pages_api.md
- Moving their things from any other system: the
`migration_guide` tool, or
https://www.vibecontrol.co.il/user_manuals/vibe_migration_guide.md
Tell the person they are connected and ask what they want to do first.
---
# Vibe Pages API — Knowledge File for AI Agents
**Document version: 2026-11-07.** (New: a slug change on a live page, while the pages server is down, changes nothing (`PUBLISH_FAILED`, field `slug`); images: small and animated uploads are stored as they are; the owner's manual for landing pages, Vibe_Pages_User_Manual.md, listed at /llms.txt; images: the server makes large uploads web-sized; what counts as a hard bounce, and a domain that stopped taking mail; every help file listed at /llms.txt; `update_contact`, a person's own change of email, name or phone; payments, owner only; copy, move and remove a list's people, `bulk_list_action`; a send hour per series message; archiving a list; who got a campaign or a series message, by status, and its file, `export_recipients`; one funnel shape and the funnel map, `get_funnels`; page answers name their forms; moving from another system has its own guide, vibe_migration_guide.md.)
**First, before anything else:** check that this copy is current. The live file
is at https://www.vibecontrol.co.il/user_manuals/vibe_pages_api.md — fetch it
with a cache-buster (e.g. `?v=20261102`, any changing value; the file is served
with a long cache lifetime) and compare its `Document version` line with this
one. If you cannot fetch it, tell the user this copy's version date and ask them
to confirm it is the newest — the same file downloads from VibeControl → API →
דפי נחיתה ("הורד קובץ ידע ל-AI"). If the version cannot be confirmed, say so
plainly before writing any integration code, and treat a 400/404/422 answer as
a sign this file may be out of date.
Every Vibe help file (the owner's manual, the courses API, the migration
guide and the others) is listed at https://www.vibecontrol.co.il/llms.txt, and
all of them joined in one file at https://www.vibecontrol.co.il/llms-full.txt.
This file documents the API that lets an agent publish a landing page in Vibe
Pages: upload images to the account's gallery, create a page from a finished
HTML document, read its validation warnings, publish it to its public address,
and unpublish it. It is written for an agent that already has the page's HTML
(for example one working from the Vibe business-agent kit) and for anyone
automating the same steps with curl or a script.
Since 2026-10-26 the same key and connection also build the mailing side of a
campaign: mailing lists, signup forms (with their design and their signup
message), described in "Lists and forms" below, and since 2026-10-27 the
campaigns themselves as drafts the owner approves ("Campaigns"). Nothing on
that side charges the wallet, and the only email it sends is a test to the
account's own addresses.
What a published page gets, no matter who publishes it: the account's
mandatory footer (three legal links, the accessibility widget, the Vibe Pages
credit) and, if the account set them, its analytics snippets (GTM, Meta pixel,
GA). The API and the admin's publish button go through the same code — there
is no way to publish a page without that system layer.
---
## Two ways in: the MCP connection, or this REST API
**Most users never call this API directly.** Vibe Pages is an MCP server at
`https://www.vibecontrol.co.il/mcp`, and connecting is that one address plus
a sign-in. No key, no header, nothing installed:
- **Codex app:** Settings → MCP servers → Add server, **Streamable HTTP**,
Name `Vibe`, URL the address above, other fields empty. Save, then
Authenticate.
- **Claude (the app or claude.ai):** Settings → Connectors → Add custom
connector, Name `Vibe`, the address, then Connect. A connector added once
shows up by itself in Claude Code signed in to the same Claude account.
Course work happens in the app's Code tab with the business folder open.
- **Claude Code in a terminal:** a project `.mcp.json` with
`{"mcpServers": {"vibe": {"type": "http", "url": "https://www.vibecontrol.co.il/mcp"}}}`,
or `claude mcp add --transport http vibe https://www.vibecontrol.co.il/mcp`;
then `/mcp` → vibe → Authenticate.
- **ChatGPT:** Plugins → Add custom MCP server, Server URL the address,
Authentication OAuth.
Authenticate (or Connect) opens the browser on the Vibe sign-in: the email
code, then a consent screen (which accounts, which areas: דפי נחיתה, דיוור),
then "אישור וחיבור". The person sees and ends connections at VibeControl →
API → החיבורים שלי; after 90 days without use the app signs in again. Menu
names change between app versions: if the user's screen differs, ask them to
describe it and guide from there. Details of a signed-in connection are in
"Signed in with Vibe" below.
The agent then has the pages tools (`prepare_upload`, `upload_image`,
`create_page`, `update_page`, `list_pages`, `get_page`, `publish_page`, `unpublish_page`,
`vibe_catalog`, `migration_guide`) and, with the mailing area, forty more
(`list_contact_lists`, `get_contact_list`, `create_contact_list`,
`update_contact_list`, `add_contact_to_list`, `update_contact`, `bulk_list_action`, `get_list_report`, `export_recipients`, `create_list_from_engagement`,
`list_form_designs`, `list_custom_fields`,
`get_form_style_guide`, `list_forms`, `get_form`, `create_form`,
`update_form`, `set_form_signup_message`, `send_test_signup_message`,
`list_campaigns`, `get_campaign`, `create_campaign`, `update_campaign`,
`duplicate_campaign`, `send_test_campaign`, `unschedule_campaign`,
`wallet_balance`, `get_funnels`, `list_automations`, `get_automation`, `create_automation`,
`update_automation`, `update_automation_message`, `add_automation_message`,
`remove_automation_message`, `reorder_automation_messages`,
`duplicate_automation`, `send_test_automation_message`, `pause_automation`,
`remove_from_automation`); a sign-in adds `list_accounts`. They do exactly what the
endpoints below do, with the same rules and the same prices; the tool list
shows only the tools the connection may call. **Files travel through upload links, never through the model:**
`prepare_upload` returns a one-line `curl` the agent runs in its own shell
(see "Upload links" below).
**A key instead of a sign-in** (scripts, or an app that cannot sign in): the
same address with one header, `Authorization: Bearer YOUR_KEY`; in Claude
Code add `--header "Authorization: Bearer YOUR_KEY"` to the command above. A
key reaches only its own account and the boxes ticked on it. Never put the
key in a prompt, a project file or a page.
The REST API below is the same thing for scripts, curl and any model
without an MCP client. Everything in this file about auth, errors, limits,
warnings, billing and the publish rule applies to both doors.
---
## Base URL
```
https://www.vibecontrol.co.il/api/v1
```
(This is the VibeControl admin host, not the VibeInbox API host. The same
`vi_` key serves both: VibeInbox calls go to `https://api.vibeinbox.app/api/v1`,
pages calls come here.)
---
## Authentication
Create a key in VibeControl → API → מפתחות API, and tick **גישה לדפי נחיתה
(Vibe Pages API)** when creating it, and **רשימות וטפסים (Vibe Inbox)** for
lists and forms. A key created without either box is an Inbox-only key: it
works for the VibeInbox API and answers `403 SCOPE_REQUIRED` here. Each box
opens its own part: a pages-only key gets `403` on lists and forms and the
reverse. Minting a pages key needs the Vibe Pages edit permission on the
account; minting a lists-and-forms key needs edit rights in דיוור. An existing
key never gains a box: make a new key.
The key is shown once — store it like a password.
Send it in a header. **Only the header form is accepted** (no `?api_key=`
query parameter: a key in a URL ends up in logs):
```
Authorization: Bearer YOUR_KEY
```
A key only sees its own account's pages and images. Another account's page
answers `404`, exactly like one that does not exist.
**Never put the key in a tool parameter, a prompt, a file in the customer's
project folder, or a page.** It belongs in the environment of the program that
calls the API.
### Signed in with Vibe (the MCP connection only)
The MCP connection also takes a sign-in instead of a key: the app (ChatGPT,
Claude, Codex) shows a "sign in" button, the person logs in with the email
code and approves which accounts and areas the agent may use. The app handles
the tokens; there is nothing to paste. The REST API below stays key-only.
A sign-in is one person, so it may reach **several accounts**. Then:
- `list_accounts` shows them (name, subdomain, the areas usable in each).
- Every other tool takes `account` (the subdomain). There is no default: a
call without it is `ACCOUNT_REQUIRED` with the list. Match the user's words
to an account, and ask when it is not clear.
- An id that belongs to another of the connection's accounts is
`ACCOUNT_MISMATCH`, naming both accounts: confirm with the user.
- The person's own permissions apply in each account, read on every call:
without edit rights in an area there, the answer is `PERMISSION_DENIED`.
- Every answer names the account it acted in (`account` in the result, and
the text starts with `[name · subdomain]`).
- The price can differ by account: read `publish_price_agorot` on the page
(0 = free, then say nothing about a price).
---
## Response and error format
All responses are JSON. Every error has the same shape:
```json
{
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "פרסום עולה ₪12 (כולל מע״מ); היתרה בארנק היא ₪4.50 — יש לטעון את הארנק",
"details": { "price_agorot": 1200, "balance_agorot": 450 }
}
}
```
`code` is stable and meant for your logic; `message` is Hebrew, meant to be
shown to the user as is. `field` names the offending parameter when there is
one; `details` carries numbers you can act on. **`field` and `details` may be
absent** — read them defensively.
| HTTP | code | meaning |
|---|---|---|
| 401 | `AUTH_REQUIRED` | no key, a malformed key, a revoked or expired key |
| 403 | `SCOPE_REQUIRED` | the key lacks the box this call needs; `details.required_scope` is `pages` or `mailing` — create a key with that box |
| 403 | `PERMISSION_DENIED` | sign-in only: the person has no edit rights in this area in that account; the owner can grant them |
| 404 | `NOT_FOUND` | no such page in this account |
| 422 | `ACCOUNT_REQUIRED` | sign-in only: name the account (`account`, the subdomain); `details.accounts` lists the connection's accounts |
| 409 | `ACCOUNT_MISMATCH` | sign-in only: the id belongs to another account of the connection; `details.belongs_to` and `details.requested` name both |
| 422 | `VALIDATION_FAILED` | a missing or bad parameter; `field` says which |
| 422 | `SLUG_CONFLICT` | the slug is taken in this account; `details.existing_page_id` names the page — continue with it rather than inventing another slug |
| 422 | `ASSET_TYPE_NOT_ALLOWED` | the uploaded file is not a JPEG, PNG, GIF or WebP (judged by its bytes, not its name) |
| 413 | `ASSET_TOO_LARGE` | an image over 2 MB, or a request body over 10 MB |
| 422 | `MAILING_NOT_SET_UP` | lists and forms: the account never opened mailing (דיוור); the owner opens it once in VibeControl |
| 402 | `INSUFFICIENT_BALANCE` | the wallet cannot cover a publish; `details` has the price and the balance |
| 409 | `IDEMPOTENCY_CONFLICT` | see Idempotency below |
| 409 | `PAGE_EXISTS` | create: the HTML's page marker names a page that exists; `details.existing_page_id` is the page to update instead |
| 409 | `PAGE_MISMATCH` | update: the HTML's page marker names a different page of the account (`details.html_page_id`); ask the user which page they meant |
| 429 | `RATE_LIMITED` | too many requests; `details.retry_after` and the `Retry-After` header say how long |
| 502 | `PUBLISH_FAILED` | the pages server did not accept the file; nothing was published or charged. With field `slug`: the old address could not be removed, so nothing changed and the page is still live there; retry in a moment |
| 502 | `SEND_FAILED` | a test email could not be sent; nothing was charged |
| 500 | `INTERNAL_ERROR` | our fault; retry once, then tell the user |
---
## The lifecycle
1. **Upload each image** the page needs — with `POST /images` (a keyed
multipart call), or through an upload link (`POST /upload-links`, then
`curl -F file=@hero.webp `, no key on that line). Use the returned
`url` in the HTML. The cap is 2 MB per file. The server makes large uploads
web-sized (anything wider than 2560 px or taller than 10,000 px is scaled
down, files over 500 KB are re-compressed, and a re-encoded file loses its
metadata, the location too). Every other file is stored as it is,
metadata included: smaller files, GIFs, animated ones, and any file the
processing would not make lighter or could not read. Strip a photo's
location before uploading when it matters. A file you size first uploads
faster and stays sharper.
2. **Create the page** with `POST /pages`: title, slug, the HTML — inline, or
by `html_upload_id` after posting the document to an upload link — and an
idempotency key. The answer includes `warnings` (advisory findings from the
HTML contract) and `html_sha256`, the hash of what was stored; compare it
with `shasum -a 256 page.html` to know the page is the file you QA'd. Fix
what you can and update.
3. **Read it back** with `GET /pages/:id` whenever you need the current state,
URL or warnings.
4. **Publish** with `POST /pages/:id/publish` — **only after the user has
explicitly asked you to publish.** Creating and updating a page is free and
invisible to the public; publishing costs money and puts the page on the
internet. Design approval is not publish approval.
5. **Report the `url`** from the publish answer to the user. That is the
proof of the work; nothing else is.
6. **Unpublish** with `POST /pages/:id/unpublish` if asked. The page returns
to draft and its address answers 404.
---
## Endpoints
### Images
#### POST /images — upload one image to the gallery
Multipart form, field `file`. Returns the permanent public URL to use in the
page.
```bash
curl -X POST https://www.vibecontrol.co.il/api/v1/images \
-H "Authorization: Bearer YOUR_KEY" \
-F "file=@hero.webp"
```
```json
{ "id": 69294, "url": "https://vibecontrol.co.il/rails/active_storage/blobs/redirect/…/hero.webp", "content_type": "image/webp", "bytes": 148220 }
```
Rules: JPEG, PNG, GIF or WebP by content (an SVG or a renamed file is
refused); at most 2 MB; at most 30 uploads per minute per key. Every API
upload is labelled "API" in the account's gallery, so the customer can find
and archive the batch.
### Upload links — the byte channel
When the caller is an agent, a file's bytes must not be typed into a tool
argument (a 150 KB image is minutes of model output and may be cut off).
Instead: ask for a single-use link, run one curl line, read the answer.
#### POST /upload-links — mint a link
```bash
curl -X POST https://www.vibecontrol.co.il/api/v1/upload-links \
-H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" \
-d '{ "kind": "image", "filename": "hero.webp" }'
```
```json
{ "upload_id": 12, "kind": "image", "upload_url": "https://www.vibecontrol.co.il/api/v1/uploads/9f3c…", "expires_at": "…", "max_bytes": 2097152, "curl": "curl -sS -F \"file=@hero.webp\" https://www.vibecontrol.co.il/api/v1/uploads/9f3c…" }
```
`kind` is `image` or `html`. The link lives 10 minutes, is bound to your
account, and is spent on its first successful use; a refused file (wrong
type, too large) gives it back. (Through MCP this is the `prepare_upload`
tool.) The answer also carries `curl_windows` — on Windows PowerShell plain
`curl` is an alias that cannot upload, so use `curl.exe` — and, for `html`,
`hash_commands`: `shasum -a 256 page.html` on Mac/Linux,
`(Get-FileHash -Algorithm SHA256 page.html).Hash` on Windows. **Compare hashes
ignoring case** (PowerShell prints upper case).
#### POST /uploads/:token — spend the link (no key)
Multipart `file`, nothing else; the token is the credential. For `image` the
answer is the same as `POST /images` (the gallery URL), plus `upload_id`. For
`html` the document is held for a day and the answer is:
```json
{ "upload_id": 13, "kind": "html", "bytes": 48210, "sha256": "…", "next_step": "create_page או update_page עם html_upload_id=13" }
```
A refused file (wrong type, too large) does not spend the link. Then
`POST /pages` or `PATCH /pages/:id` with `"html_upload_id": 13` instead of
`html`. An unknown id, another account's id, or a link nothing was posted to
answers `422` with `field: "html_upload_id"`.
### Pages
A page object looks like this everywhere:
```json
{
"id": 183,
"title": "דף הכנס",
"slug": "conference",
"page_type": "code_page",
"status": "draft",
"url": "https://vibe.vibepages.co.il/conference",
"published_at": null,
"has_unpublished_changes": false,
"updated_at": "2026-10-07T13:03:13.298+03:00",
"forms": [ { "id": 31, "name": "הרשמה לכנס", "list_ids": [412] } ],
"warnings": [ { "code": "PLATFORM_JS_MISSING", "message": "…" } ]
}
```
`forms` lists the Vibe forms on the page, for every page type (a code page's
`data-vibe-form` divs, a block-builder page's form blocks), with the lists
each one adds to, so you can follow page → form → list.
`url` is where the page will be (or is) public: the account's subdomain on
`vibepages.co.il` plus the slug. `status` is `draft` or `published`.
`has_unpublished_changes` is true when the stored HTML differs from what is
live. `warnings` appears on create, update and get.
#### GET /pages — list the account's pages
`?slug=conference` narrows to one slug, `?url=` to the page behind a link the
user pasted (its own domain or `.vibepages.co.il/`; another
account's link finds nothing). Newest first, up to 100. Each page has
`publish_price_agorot` (0 once it was paid) and `page_marker`.
#### GET /pages/:id — one page
Add `?include_html=1` to get the stored HTML back in `html`.
#### POST /pages — create a page from HTML
```bash
curl -X POST https://www.vibecontrol.co.il/api/v1/pages \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "דף הכנס", "slug": "conference", "html": "…", "idempotency_key": "6f1c…-uuid" }'
```
| parameter | required | notes |
|---|---|---|
| `html` or `html_upload_id` | one of them | the complete document inline (up to 1 MB after image extraction), or the id of a document posted to an upload link |
| `idempotency_key` | yes | see Idempotency |
| `title` | no | shown in the admin; default "דף קוד" |
| `slug` | no | lowercase letters, digits and hyphens; unique per account; default `code-xxxx` |
Answers `201` with the page object plus `extracted_images`: if the HTML
carried base64 `data:image` images, they were moved into the gallery and the
HTML rewritten to permanent URLs (up to 30 images of 2 MB each). Prefer
uploading images first; extraction is a safety net, not the path.
#### PATCH /pages/:id — update title, slug or HTML
Send only what changes. `idempotency_key` is optional here. Changing the slug
of a published page removes the old address at once; the new address goes
live on the next publish (the answer says so in `notice`). If the pages
server cannot remove the old address, nothing changes and the answer is
`502 PUBLISH_FAILED` with field `slug`: the page is still live at the old
address; retry in a moment.
#### POST /pages/:id/publish — publish (charged)
```bash
curl -X POST https://www.vibecontrol.co.il/api/v1/pages/183/publish \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "idempotency_key": "9b2e…-uuid" }'
```
```json
{ "id": 183, "status": "published", "url": "https://vibe.vibepages.co.il/conference", "already_published": false, "charged_agorot": 1200, "warnings": [] }
```
Synchronous: when the answer arrives the page is live at `url`. The account's
legal documents (terms, privacy, accessibility) are refreshed on the pages
server by a background job within a minute. A page with no HTML is refused
with 422 before anything happens.
**Free for accounts that already pay Vibe:** a course subscription, or an
active mailing customer (paid real money into the wallet, and sent a campaign
in the last 30 days, or in the last 90 days with at least ₪50 in the wallet).
For them `publish_price_agorot` is 0 and nothing is ever charged: say nothing
about a price. For every other account:
**Price: ₪12 per page, once, VAT included** (1,200 agorot), debited from the
account's VibeInbox wallet on the page's first publish, shown in the wallet history as "פרסום דף דרך API".
If the balance is short, the answer is `402 INSUFFICIENT_BALANCE` with the
price and the balance, nothing is published, and the user must top up the
wallet in VibeControl → ארנק. **Do not retry a 402; ask the user.**
**Every later publish of the same page is free**, changed or not: the
answer is `already_paid: true`, `charged_agorot: 0`. If the page is published
and its HTML has not changed since, the answer is `already_published: true`
and nothing is pushed. `publish_price_agorot` on the page (create, update,
get) says what the next publish costs: 1200 before the first paid publish,
0 after it.
#### POST /pages/:id/unpublish — take the page down (free)
The file is removed from the pages server and the page returns to `draft`.
The public address answers 404 immediately. `idempotency_key` optional.
Unpublishing a draft is a harmless no-op.
---
## Idempotency
`POST /pages` and `POST /pages/:id/publish` require an `idempotency_key`
(also accepted as an `Idempotency-Key` header). The rules:
- **One key per attempt.** Generate a fresh random key (a UUID) for every new
attempt. Reuse a key only when you are retrying the *same* request after a
timeout or a network error.
- **A success is stored.** Repeating a successful request with the same key
returns the stored answer and does nothing again — no second page, no
second publish, no second charge.
- **A failure is not stored.** After a 4xx or 5xx the same key may be used
again and the request runs for real.
- **Same key, different request → `409 IDEMPOTENCY_CONFLICT`.** The key is
bound to the endpoint, the resource and the request body. A key used on
page 5 cannot be reused on page 6, and a changed body under an old key is
refused rather than answered with the old success.
- **Still running → `409` with `details.in_progress: true`** and a
`Retry-After` of 15 seconds. The first request is still being processed.
Wait, then call `GET /pages/:id` to see what happened before retrying.
- Keys are compared case-insensitively and kept for seven days; they are
scoped to the account.
**After a timeout on publish, never retry blindly.** Call `GET /pages/:id`
first: if `status` is `published` and `has_unpublished_changes` is false, the
publish went through — report the `url` and do not publish again.
---
## Warnings (the HTML contract)
Warnings never block. They tell you where the document departs from what a
Vibe page should be. Each has a stable `code` and a Hebrew `message`.
| code | what it means |
|---|---|
| `PLATFORM_JS_MISSING` | the platform script is not loaded at the end of the body: `` |
| `DATA_IMAGES_PRESENT` | base64 images remain in the HTML — upload them and link instead |
| `FORM_ELEMENT_PRESENT` | the HTML has a `