# 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 `
`. A Vibe form is `
` plus the SDK script, never a ``; a hand-written form will not reach VibeInbox | | `VIBE_FORM_WITHOUT_SDK` | `data-vibe-form` is present but the SDK script is not: `` (any vibecontrol host is accepted) | | `FONT_AWESOME_FOREIGN_HOST` | Font Awesome loaded from somewhere other than `https://assets.vibepages.co.il/vp-assets/fa/css/all.min.css` | | `LOCALHOST_REFERENCE` | the HTML points at localhost or 127.0.0.1 | | `RELATIVE_ASSET_PATH` | `./` or `../` paths — a published page has no local files | | `MISSING_LANG_DIR` | `` lacks `lang="he"` or `dir="rtl"` | The rest of the contract (single self-contained file, a root element with a unique id, CSS and JS scoped to that root, no styling of `html`/`body`, images under 500 KB each and 2.5 MB in total, lazy loading below the fold) is in the business-agent kit's `systems/vibe-pages/html-contract.md`. The server checks what it can; the kit's QA list covers the rest. **Forms:** take the embed code from `create_form` / `get_form` (or from VibeControl → דיוור → טפסים), paste it unchanged into the HTML, and never invent one. The code is a `
` plus one script tag; there is no `action` or hidden input to preserve, because there is no ``. --- ## Lists and forms A campaign's mailing side: the list people join, the signup form that adds them, its look, and the email they get when they sign up. Needs a key with the **רשימות וטפסים** box. Free; **nothing here sends an email, charges the wallet or deletes anything** (one exception, below: a person added to a list enters that list's message series, as with any signup; see "Message series"). A key sees only its own account: another account's list or form answers `404`, like a missing one. ### Lists | REST | MCP tool | what | |---|---|---| | `GET /contact-lists` | `list_contact_lists` | every list with `contacts`, `sendable` (consented, not unsubscribed), `unsubscribed` and `archived`; archived lists only with `include_archived` | | `GET /contact-lists/:id` | `get_contact_list` | one list, plus the forms that add people to it | | `POST /contact-lists` | `create_contact_list` | `name` (unique in the account), `description` | | `PATCH /contact-lists/:id` | `update_contact_list` | rename, change the description, `archived: true` / `false` | | `POST /contact-lists/:id/contacts` | `add_contact_to_list` | one person who asked to join: `email`, optional `first_name`, `last_name`, `phone` | | `PATCH /contacts` | `update_contact` | one person's own change of details: their current `email`, and any of `new_email`, `first_name`, `last_name`, `phone` (the addresses go in the body) | | `POST /contact-lists/:id/bulk` | `bulk_list_action` | copy or move the list's people to `target_list_id`, or remove them from this list: `action`, `emails` (optional, up to 1,000), `dry_run` (default true) | | `GET /contact-lists/:id/report` | `get_list_report` (`list_id`) | who is still in touch: the engagement groups, people, `sendable`, `said_no`, students, insights | | `GET /campaigns/:id/report` | `get_list_report` (`campaign_id`) | the same for the people a campaign reached (kept 12 months), plus `recipients`; takes `status`, `from`, `to` | | `GET /automations/:id/messages/:message_id/report` | `get_list_report` (`automation_id`, `message_id`) | the same for the people one series message reached | | `POST /recipients-export` | `export_recipients` | the file of who got a campaign or a series message: a 10-minute link and a `curl` line | | `POST /contact-lists/from-engagement` | `create_list_from_engagement` | a new fixed list: `source_list_id`, `source_campaign_id`, or `source_automation_id` with `source_message_id`; `status`, `from`, `to`, `groups`, `top`, `name` | **Copy, move, remove a list's people (`bulk_list_action`).** Everyone in the list, or only the given `emails` (up to 1,000). Nobody is deleted and nobody is unsubscribed: `remove` takes people out of this list only, and they stay in the account and their other lists. **`dry_run` is true by default:** the answer says how many (`people`), who is already in the destination (`already_in_target`, never added twice), and in `notes` what happens to series: leaving the list that brought someone into a series ends their run there. Show the owner, and only after their yes call again with `dry_run: false`. **A destination list that starts an active series is refused** (adding to it sends mail): the owner does that in VibeControl (the list page: tick the people, choose the action). The general list can only be copied from, never into. Over 500 people run in the background (`queued: true`). Free. **The archive.** `archived: true` moves an old list out of the owner's way: it leaves the lists screen and every "choose a list" screen in VibeControl (a campaign's lists, a form's lists, a series' trigger list, an import), except where it is already attached. Nothing is deleted and no sending changes: a series it starts keeps running, a form keeps adding to it, a campaign scheduled to it still goes. The answer's `notice` says which series and forms still use it. Show the owner the lists you will archive before you do it; `archived: false` brings one back. The general list cannot be archived. One list has `general_list: true`: the account's general list. Every contact joins it automatically, from every form; it cannot be changed or emptied. A taken name answers `422` with `details.existing_list_id`: continue with that list instead of inventing another name. **Adding a person (`add_contact_to_list`): only with consent.** Use it only for someone who asked to join and agreed to receive email, for example an audience that cannot open signup pages and replies to an email "add me to the list, I agree to receive email". **Calling it is the owner's statement that this consent was given;** it is recorded on the contact (consent source `agent`, today's date). Never for addresses that were bought, copied from elsewhere, guessed or collected without that consent; when the owner asks for that, say no and explain. One person per call (up to 300 an hour per key). - The address must be one the mail provider accepts: no dot at the start or before the @, no two dots in a row, a real ending (`.com`, `.co.il`). Otherwise the answer is `422` ("כתובת המייל לא תקינה"); ask the person. - A new person is created (`created: true`, answer `201`) and also joins the general list. - An existing person is added to the list; names and phone already on record are kept. Someone who had unsubscribed is subscribed again (`resubscribed: true`): the request is a new consent. Already in the list: `added_to_list: false`, nothing changes. - A person whose details were erased at their request keeps no address on record; if they ask again, they are added as a new person. - The tool sends nothing itself, but **the list's automations start for the person** (for example a welcome series), exactly as with a signup form. **Changing a person's details (`update_contact`): only at their request.** Use it when the person asked, for example a reply "please change my email to …". Find them by their current `email`; send only the fields to change (`new_email`, `first_name`, `last_name`, `phone`). A field left out is kept; a blank name or phone clears it. The same person changes, so their history, lists and series stay with them; never add the new address as a new person and remove the old one. Shares the 300-an-hour budget with adding people. - Refused (`422`, field `new_email`, nothing changed) when the new address already belongs to another person in the account, or is not one the mail provider accepts. Tell the owner; two people with the two addresses may need the owner to decide in VibeControl. - No person with that address: `404`. Ask for the address they joined with. - A person whose old address bounced, or whose domain stopped taking mail, gets mail again at the new one (`mail_again: true`). A person who unsubscribed themselves stays unsubscribed, whatever the address (the answer's `notice` says so). - `changed` lists the fields that changed; it sends nothing. The owner sees the change among the agent's actions on the devices page. **Engagement groups.** Every contact has a score from our own sends and opens; the groups are `hot` (חם, opened the last email we sent), `warm` (פושר, one of the last 3), `cool` (קר, the last 6), `dormant` (רדום, the last 12), `old` (ישן, not in 12 sends or over 180 days), `off` (כבוי, 3 or more sends and never opened) and `new` (חדש, not enough sent yet to tell). The report counts only people who can receive mail; `said_no` is apart. `create_list_from_engagement` makes a **snapshot**: the members are fixed and the default name carries the source, the choice and the date. "A hot list of 4,000 from list 10" is `source_list_id: 10, top: 4000`; the most engaged come first. To leave a group out of a campaign, make a list from it and give it in the campaign's `exclude_list_ids`. Free; nothing is sent. **Who got a campaign or a series message.** The report of a campaign, or of one series message (`automation_id` with `message_id`, the message's id from `get_automation`), also has `recipients`: `sends`, and `statuses`, how many sends reached each status, only the statuses that happened: `sent` (all), `delivered`, `opened`, `clicked`, `bounced`, `complained`. Each send counts at the furthest step measured, and an open or a click also counts as delivered. `status`, `from` and `to` (send days, `YYYY-MM-DD`, Israel time) narrow the people of the whole report: the groups, the numbers, the file and a list made from it. Sends are kept 12 months. - **The file:** `export_recipients` (or `POST /recipients-export`) answers a `download_url` that works for 10 minutes without a key, and a `curl` line (`curl_windows` on Windows). Run it in your own shell, in the owner's folder: the file is exact and its rows never pass through you. Then tell the owner where it is. Columns (Hebrew headers, UTF-8 with a BOM for Excel): email, first name, last name, source, message, sent at, status, opened at, clicked at. - **A list of them:** `create_list_from_engagement` with the same source and filter and neither `groups` nor `top` takes everyone in the filter who can receive mail: "a list of everyone who opened message 1" is `source_automation_id`, `source_message_id`, `status: "opened"`. A list as a source still needs `groups` or `top`. ### Forms | REST | MCP tool | what | |---|---|---| | `GET /forms` | `list_forms` | every form: active, submissions, `embed_code` | | `GET /forms/:id` | `get_form` | everything: settings, fields, lists, thank-you url, CSS, `embed_code`, `stats`, `signup_message` | | `POST /forms` | `create_form` | a new form (fields below) | | `PATCH /forms/:id` | `update_form` | only what changes | | `GET /form-designs` | `list_form_designs` | the saved looks, for `design_id` | | `GET /custom-fields` | `list_custom_fields` | the account's extra fields, for `fields` | | `GET /forms/style-guide?form_id=&page_id=` | `get_form_style_guide` | how to design a form with CSS, its current CSS, and the look of the page it goes on (`page_id` or `slug`) | | `PUT /forms/:id/signup-message` | `set_form_signup_message` | the signup email's subject and HTML | | `POST /forms/:id/signup-message/test` | `send_test_signup_message` | a [TEST] copy to the account's own addresses | ```bash curl -X POST https://www.vibecontrol.co.il/api/v1/forms \ -H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "name": "הרשמה לוובינר", "list_ids": [412], "thank_you_url": "https://vibe.vibepages.co.il/thanks", "settings": { "show_phone": false, "submit_button_text": "אני בפנים" } }' ``` The form's fields: `name` (required on create), `design_id`, `custom_css`, `settings`, `thank_you_url`, `pass_fields_to_thank_you` (the visitor's details added to the thank-you url), `webhook_url`, `list_ids`, `fields` (`[{ "custom_field_id": 7, "required": true }]`, in order), `active`, and on `update_form` only `signup_message_enabled: false` (switches the form's own signup message off, for the funnel shape below; switching it on is the owner's click in the admin, so `true` is refused). `list_ids` and `fields` replace the whole set. A form set up in the admin may also remove people from a list (`remove_lists` in the answer); those stay as they are, and a list there cannot go into `list_ids`. `settings` takes only these keys: `show_name`, `require_name`, `show_phone`, `require_phone`, `show_terms`, `require_terms`, `terms_text`, `submit_button_text`, `success_message`, `enable_double_optin`, `redirect_after_submit`, `language` (`he` or `en`); any other key is refused by name. Email is always a field. **Consent to email is required in every signup form.** `show_terms` and `require_terms` are always true; sending `false` is refused, and editing an older form that had them off turns them on (the answer's `notice` says so). `terms_text`, the checkbox's wording, may change but not be empty. Put the `embed_code` into the page HTML as is. A form is never deleted here: `active: false` turns it off. ### The form's look A form's HTML is fixed; its look is CSS only, and `custom_css` **replaces** the chosen design's CSS. The way to work: `get_form_style_guide` (with the form's id, to get its current CSS) → ask the owner the guide's few short questions → write one CSS block → `update_form` with `custom_css` → look at a local HTML file that holds the `embed_code`. **A form for a page matches the page.** Building a form for a landing page, pass the page too (`page_id` or `slug`). `page_style` returns what the page itself uses: for a code page, its background and text colours, its first button's and input's rules, its `:root` variables and the colours it uses most (with `var()` resolved); for a builder page, its theme's colours. Nothing is guessed: when nothing can be read, `found` is `false`. With a page that was read, the agent asks the owner one question only, "להתאים את הטופס לעיצוב הדף?" (yes by default), and on yes writes the CSS from `page_style`. `custom_css: ""` returns to the design. Pages that embed the form show a change **within 5 minutes** (the form's settings are cached). The CSS must not pull from other hosts: no `@import`, no `@font-face`, `url(…)` only for gallery images (`https://vibecontrol.co.il/rails/active_storage/…`); 50 KB at most. ### The signup message `set_form_signup_message` writes the email a new signup gets: `subject` and the whole email `html` (table-based, inline styles, RTL for Hebrew; images as gallery urls). Refused, as in campaigns: `` - השתמש באייקונים רק כשהם תורמים להבנה. - הקפד על ניגודיות, גדלי טקסט, מצבי focus ונגישות בסיסית. - שמור על היררכיה ברורה בין כותרות, טקסט, כרטיסים וכפתורים. - צור אזורים לתמונות וללוגו באופן שקל להחליף בהם את הקישור מאוחר יותר. - אל תשאיר תמונות שבורות או קישורים לא פעילים. - אם סופק מספר וואטסאפ, צור קישור `wa.me` למספר בפורמט בינלאומי, עם הודעה מוכנה מראש. - אל תשתמש בטופס דמה שאינו שולח מידע. - שמור את התוצאה בקובץ שביקשתי. **טפסים:** - אם אותו קוד טופס (או אותו מזהה) מופיע בחומרים יותר מפעם אחת — הטמע מופע פעיל **אחד בלבד**, במיקום ההגיוני ביותר במסלול השכנוע. - כל שאר כפתורי הפעולה בדף מקשרים אליו באמצעות עוגן (`#form`). - אין להשאיר מעטפת, כרטיס או אזור ריק במקום שבו הוסר מופע כפול. - אין לבנות טופס חלופי או טופס דמה. **מגבלות טכניות של המערכת (חובה — דף שחורג נדחה בשמירה):** - תמונות מגיעות בקישור, לא מוטמעות בקוד. את התמונות מעלים לגלריה של Vibe (גלריה ← "העתקת קישור קבוע") ומצרפים את הקישורים לחומרים; קובץ מקורות של דף קיים כבר מכיל קישורים כאלה. אין להמיר תמונות ל־base64 ואין להטמיע קבצי תמונה מקומיים בתוך ה־HTML. - אם אין ברירה ותמונה בכל זאת מוטמעת: עד 30 תמונות מוטמעות, עד 2MB כל אחת, בגודל אינטרנטי (רוחב עד 1600px, JPEG או WebP). המערכת מעבירה אותן לגלריה בשמירה ומחליפה אותן בקישורים. - הקובץ כולו — עד 10MB. קוד הדף ללא התמונות — עד 1MB. דף מכירה אמיתי הוא עשרות KB. **שפה עיצובית (העדפות מחייבות):** - אין להשתמש במקפים ארוכים (—) בטקסטים ובכותרות. העדף נקודה, פסיק או ניסוח מחדש. - ליד כותרות משנה: נקודה קטנה (Font Awesome) — לא קו דקורטיבי. - אין לשלב תמונה שמכילה טקסט כאשר אפשר להציג אותו כטקסט HTML נגיש. - כל אלמנט בדף חייב תפקיד ברור — מידע, אמון, הבנה או פעולה. הסר עיטורים וכרטיסים שאינם תורמים אחד מאלה. ## שלב 6 — בקרת איכות לפני המסירה: 1. ודא שכל הטענות נשענות על חומרי המקור. 2. ודא שאין הבטחות שאי אפשר לעמוד בהן. 3. בדוק שהכותרת הראשית מתחברת לרצון של הקורא. 4. בדוק שכל אנלוגיה באמת מסבירה את הבעיה. 5. בדוק שהקריאה לפעולה ברורה ופועלת, ושכל כפתורי הפעולה מובילים לטופס הפעיל. 6. בדוק את מבנה ה־HTML, ושהקוד המודבק לבדו מספיק להצגת כל הרכיבים. 7. ודא שהדף קריא ונוח גם במובייל צר. 8. הסר טקסטים זמניים, הערות פנימיות ופרטים מומצאים. 9. ודא שכל אייקון מוצג בפועל (לא ריבוע, תו חסר או placeholder), ושכולם מהגרסה החינמית של Font Awesome. 10. ודא שאין טופס כפול, מעטפת טופס ריקה, או אלמנט ריק/שבור. 11. ודא שלא שובצה גרפיקה מיושנת ושאין תמונה עם טקסט שניתן להציג כ־HTML. 12. ודא שאין מקפים ארוכים או קווים דקורטיביים בניגוד לשפה שהוגדרה. 13. אם קיימת כתובת פרסום — בדוק את הדף גם בסביבה המקוונת, לא רק כקובץ מקומי. 14. ודא שאין בקוד תמונות מוטמעות (base64) — כל התמונות בקישורים — ושהקובץ שוקל פחות מ־1MB. 15. אל תסתפק בהצעות — השלם בפועל את הקובץ. בסיום, הצג לי: - קישור לקובץ ה־HTML המוכן. - תקציר קצר של ההבטחה המרכזית ומבנה השכנוע. - רשימה קצרה של פרטים שעדיין דורשים ממני השלמה, אם קיימים. כאשר אשלח תיקונים בהמשך, ערוך את אותו קובץ ושמור על כל השינויים שכבר אושרו. אל תכתוב את הדף מחדש ואל תחזיר ניסוחים קודמים. --- הערה: אפשר להשאיר את השדות בסוגריים ריקים אם כל המידע כבר נמצא בחומרים המצורפים — הפרומפט מורה למערכת להסיק אותם בעצמה. --- # מדריך הכנת תוכן לדף נחיתה — Vibe Pages מסמך זה מיועד למודל בינה מלאכותית (או לסוכן) שמכין טקסט גולמי עבור מערכת דפי הנחיתה של Vibe Pages. הפלט הנדרש: טקסט רציף ונקי, מוכן להדבקה בשדה "תוכן הדף". המערכת תחלק אותו לבלוקים ותעצב אותו בעצמה. ## עקרון העל כתיבה שיווקית אפקטיבית אינה ממציאה יתרונות — היא ממקדת את תשומת הלב אל היתרונות האמיתיים. התועלת ללקוח באה תמיד לפני תיאור המוצר ("למה" לפני "מה"). ## מבנה מומלץ לתוכן סדר הנושאים הבא מפיק את הדף הטוב ביותר. דלג על מה שלא רלוונטי — אל תמציא תוכן שאין לך: 1. **כותרת ראשית** — משפט אחד שמוכר את הערך ללקוח (לא את שם העסק). שאלה מסקרנת, הבטחת תועלת או בעיה-פתרון. 2. **כותרת משנה** — הרחבה קצרה של ההבטחה. 3. **פרטי אירוע** (אם רלוונטי) — תאריך, שעה, מיקום, והערה כמו "ההשתתפות ללא תשלום — בהרשמה מראש בלבד". 4. **נתוני אמון** — 3-4 שורות קצרות: שנות ניסיון, מספר לקוחות, הסמכות, פרסומים. 5. **הסיפור / הכאב** — פסקאות שמתארות את המצב של הלקוח, האמפתיה, והרגע שבו מבינים שהבעיה אינה מה שחשבו. זה הלב הרגשי של הדף — אל תקצר אותו. 6. **מה מקבלים / מה לומדים** — רשימת נקודות, כל נקודה בשורה נפרדת. 7. **יתרונות ותכונות** — לכל פריט: כותרת קצרה ואחריה שורת הסבר. 8. **עדויות** — לכל עדות: הציטוט במירכאות ובשורה נפרדת מתחתיו השם המלא (ותפקיד אם יש). עדות טובה מכילה תוצאה קונקרטית ("ירדתי 20 קילו בשלושה חודשים"). 9. **קישורי וידאו** (אם יש) — שורת URL מלאה של YouTube או Vimeo לכל סרטון. 10. **מי מאחורי המוצר** — שם, סיפור אישי קצר, הסמכות. 11. **שאלות נפוצות** — כל שאלה בשורה, והתשובה בפסקה מתחתיה. 12. **מחיר** (אם רלוונטי) — המחיר, אפשרויות תשלום, והטבות. 13. **קריאה לפעולה לסיום** — משפט סוגר רגשי + טקסט לכפתור (מנוסח כתועלת: "אני רוצה להבין את שורש הבעיה" ולא "שלח"). 14. **פרטי העסק** — שם העסק, שורת זכויות יוצרים. ## כללי כתיבה - טקסט נקי בלבד: ללא HTML, ללא Markdown (בלי `**`, `#`, כוכביות או סולמיות). - הפרד נושאים בשורה ריקה. אפשר להוסיף שורת כותרת קצרה לפני כל נושא. - עברית טבעית וישראלית. פנייה ישירה בלי בעיית זכר/נקבה: מילים בכף סופית (שלך, ברצונך), לשון עבר (חיפשת, ניסית), שם הפועל (כדאי להירשם), או לשון אנחנו. - עובדות בלבד: אל תמציא מספרים, תאריכים, עדויות או הישגים. אם חסר נתון — השאר מקום ריק וסמן [להשלמה]. - אורך: דף עשיר דורש תוכן עשיר. אל תקצר סיפור רגשי לפסקה אחת. ## מה לא לכלול - הוראות עיצוב, צבעים או פונטים — המערכת מעצבת בעצמה. - קוד הטמעה של טופס — מוזן בשדה נפרד בטופס יצירת הדף. - קישורים לרשתות חברתיות או תפריטי ניווט. ## דוגמה קצרה למבנה ``` הסוכר עולה? המשקל מטפס? לחץ הדם כבר לא מה שהיה? יכול להיות שכל אלה סימנים של אותו תהליך. 📅 יום רביעי | 8.7.2026 🕣 20:30 ההשתתפות ללא תשלום — בהרשמה מראש בלבד. 15 שנות ניסיון אלפי משתתפים מה תלמדו בהדרכה? איך באמת נוצרים סוכרת ועודף משקל. מה קורה בגוף לפני שהבדיקות משתנות. "ירדתי 20 קילו בפחות משלושה חודשים." אלחנן פוקס האם ההדרכה מתאימה גם למתחילים? כן. דווקא הבנה נכונה בתחילת הדרך חוסכת טעויות. ``` בהצלחה! --- # Vibe Courses Content API — Knowledge File for AI Assistants **Document version: 2026-11-02.** (New in this version: create a lesson, `POST /courses/:id/lessons`, with an idempotency `key`; lesson keys in the lessons list; how a new lesson behaves for students.) **First, before anything else:** check that this copy is current. The live file is at https://www.vibecontrol.co.il/user_manuals/vibe_courses_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. This file documents the API for feeding content into a Vibe academy course: list courses and lessons, create a lesson (with its video in the same call), add content blocks (a video or text, with a title), place them at the top, replace or trim recurring content, and remove blocks. Upload it to an AI model (Claude, GPT, etc.) to get help building automations (Make, n8n, Zapier, a script). What students see: every block appears in the lesson in the academy, in the order shown by `position`, immediately — there is no publish step and no cache to wait for. Removed blocks disappear for students at once and can be restored by the course owner from the lesson editor in VibeControl. --- ## Base URL: two servers for now The course endpoints are moving to the VibeControl server. Until the move is finished, use each endpoint on the server named here: | Endpoints | Base URL | | --- | --- | | Courses and lessons: `GET /courses`, `GET /courses/:id/lessons`, `POST /courses/:id/lessons` | `https://www.vibecontrol.co.il/api/v1` | | Blocks: everything under `/lessons/:id/blocks`, `/appendixes/:id/blocks`, `/blocks/:id` | `https://api.vibeinbox.app/api/v1` | The same API key works on both. (`GET /courses` and `GET /courses/:id/lessons` still answer on `api.vibeinbox.app` too, without lesson keys; prefer VibeControl.) When the blocks move as well, this file gets a new `Document version` and the table changes; the old addresses keep working for a while after that. --- ## Authentication Create a key in VibeControl → API → מפתחות API. It is shown once; store it like a password. **For the VibeControl server the key needs the "קורסים דיגיטליים" permission** (a checkbox when you create the key; an existing key without it answers `403` there; create a new key with the box ticked). Send it in a header (preferred): ``` Authorization: Bearer YOUR_KEY ``` or, on `api.vibeinbox.app` only, as a query parameter: `?api_key=YOUR_KEY`. VibeControl accepts the header only. A key only sees its own account's courses. Another account's course, lesson or block answers `404`, exactly like one that does not exist. --- ## Response Format All responses are JSON. Errors carry the reason. On `api.vibeinbox.app`: ```json { "error": "Send embed_code or text, not both" } ``` On `www.vibecontrol.co.il` the error is an object; `message` is in Hebrew and `field` names the parameter at fault: ```json { "error": { "code": "VALIDATION_FAILED", "message": "חסרה כותרת לשיעור", "field": "title" } } ``` HTTP status codes: `200` Success, `201` Created, `401` Unauthorized (missing or wrong key), `403` Forbidden (a system key), `404` Not found (or not yours), `422` The request was refused — the `error` says why and nothing was saved, `429` Too many requests (more than 120 writes a minute with one key — slow down and retry; creating lessons has its own limit, 60 an hour per key). Parameters can be sent as form fields, JSON body (`Content-Type: application/json`) or query string. --- ## Concepts - **Course** — a workshop in the academy. Has lessons and appendixes. - **Lesson / Appendix** — a page of content. Appendixes are pages outside the lesson sequence; both hold blocks and use the same endpoints under `/lessons/...` or `/appendixes/...`. - **Block** — one piece of content in a lesson: a video/embed or a text, with an optional title shown above it. A block holds ONE kind of content — send `embed_code` or `text`, never both. - **Position** — the block's place in the lesson, 1 = first. `position: "top"` puts a new block first. - **Stream (`key` + `keep`)** — a name you give to a recurring kind of content inside one lesson (e.g. `parasha`, `club`, `facilitators`). Adding a block with a key keeps only the newest `keep` blocks of that stream and removes the older ones in the same call. Default `keep` is 1, which means "replace". This lets an automation replace or rotate content without remembering block ids. Streams are per lesson: the same key in two lessons is two streams. --- ## Endpoints ### Courses Server: `https://www.vibecontrol.co.il/api/v1` (see Base URL). #### GET /courses: the account's courses ```json { "courses": [ { "id": 16139, "title": "Course name", "archived": false, "lessons_count": 5 } ], "total": 1 } ``` `lessons_count` counts lessons that are not deleted. Deleted courses are not listed. #### GET /courses/:id/lessons: a course's lessons and appendixes ```json { "course": { "id": 16139, "title": "Course name" }, "lessons": [ { "id": 162826, "kind": "lesson", "title": "Lesson 1", "position": 1, "key": null, "section_id": null }, { "id": 162900, "kind": "lesson", "title": "מפגש 26/10/2026", "position": 2, "key": "vimeo-1131234567", "section_id": null } ], "appendixes": [ { "id": 4001, "kind": "appendix", "title": "Extra material" } ] } ``` Lessons are in course order (`position`, 1 = first). `key` is the lesson's idempotency key when it was created through the API with one, else `null`. To find the lesson of a key, read this list and match `key`. #### POST /courses/:id/lessons: create a lesson Creates a lesson at the end of the course (or where `position` says), optionally with its blocks, in one call. Answers `201` with the lesson. | Parameter | Required | Description | | --- | --- | --- | | `title` | yes | The lesson title, up to 255 characters. | | `key` | | **Idempotency key**, up to 100 characters, unique per course. See below. | | `position` | | `top`, `bottom` (default) or a number from 1 (that place; the lessons after it move down). A number past the end means last. | | `blocks` | | Up to 20 blocks created inside the new lesson, in order. Each takes `title`, `embed_code` or `text` (one of them), `raw`, and optionally a block `key`, exactly like `POST /lessons/:id/blocks`. | | `kind` | | `lesson` (the default). Appendixes cannot be created through the API yet. | The lesson and its blocks are saved together: if any block is refused (`422`), nothing is created, so a lesson is never left without its video. **`key` makes the call safe to retry.** If a lesson with this `key` already exists in the course, nothing is created: the answer is `200` with the existing lesson, `"existing": true`, and its current blocks. `blocks` sent with a repeated key are ignored. Two retries arriving at the same moment still make one lesson. Use something unique per item, e.g. `vimeo-