--- name: keepp-page description: Use when a user wants to build or manage their Keepp page — adding, arranging, or removing blocks (links, cards, products, bookings, forms, maps, profile, socials, headers, text), changing the layout, or restyling the theme — through the Keepp Agent API. Requires a Keepp Pro API key (keepp_live_…). --- # Building a Keepp Page ## What Keepp is Keepp gives a creator or small business one page at `keepp.link/theirhandle` that replaces a whole toolkit: link-in-bio, storefront, booking page, and forms on a single URL they own. A page is an **ordered list of blocks** on a two-column grid. Each block is `full` width (a row to itself) or `half` (pairs side-by-side with the next half block). List order is visual order — the array you send is the page, top to bottom. ## Authentication Every request needs the owner's API key as a bearer token: ``` Authorization: Bearer keepp_live_… ``` Keys are generated by a **Pro** account in the Keepp dashboard under "AI Agent". If you don't have one, ask the user. Base URL: `https://api.keepp.link`. Rate limit: 60 requests/minute. ## The three endpoints | Method | Path | Purpose | |--------|------|---------| | GET | `/api/v1/page` | Read the whole page: `version`, `url`, `publishedAt`, `blocks`, `theme`. | | PUT | `/api/v1/page` | **Replace** the whole page. Send `blocks`, optional `theme`, optional `version`. | | GET | `/api/v1/catalog` | List the `products` and `bookingUnits` this page can reference. | ### PUT replaces everything — read first `PUT` is not a patch. **Any block you leave out is deleted.** There is no undo. So the loop is always: 1. `GET /api/v1/page` 2. Modify the `blocks` array you received — change one, add one, reorder, remove one 3. `PUT` the **entire** array back Never build a `blocks` array from scratch for a page that already exists, unless the user explicitly asked to replace the whole thing. If you received 25 blocks and you're adding one, send 26. The response tells you what happened, so you can check yourself: ```json { "ok": true, "data": { "version": 13, "publishedAt": "2026-07-21T10:00:00.000Z", "blockCount": { "before": 25, "after": 26 } } } ``` If `after` is lower than you intended, you dropped blocks — say so and restore them from your earlier `GET`. **A full page is roughly 6,000 output tokens**, which exceeds the default `max_tokens` on many API clients (often 4096). If your response is cut off mid-array the request fails as malformed JSON with a `400`. Raise your output limit rather than sending a shortened page. ### Ids Every block has an `id` (a UUID). **Keep the ids of blocks you received** — they're what the page's per-block share links point at. Omit `id` on a genuinely new block and the server mints one. ### Concurrency Pass the `version` from your `GET` to make the write fail rather than overwrite someone editing at the same moment — you'll get `409 VERSION_CONFLICT`, then re-`GET` and reapply. Omit `version` and your write always wins. ## Block types Every block has `type`, `size`, an optional `align`, and an optional `style`. - `size`: `"full"` or `"half"` — most types only allow `full` (see the table). - `align`: `"left" | "center" | "right"` — only on the types marked below. - `style`: `{ "bg": "#hex", "text": "#hex" }` — per-block color override, silently dropped on types that can't render it. | type | sizes | align? | content fields | |---|---|---|---| | `link` | full | – | `title`*, `url`*, `iconUrl` | | `preview-link` | half, full | yes | `title`*, `url`*, `imageUrl`* | | `card` | half, full | yes | `kind`*, `title`*, `mediaUrls`, `description`, `aspect`, + per-kind (below) | | `header` | full | yes | `text`*, `showInNav` | | `subheader` | full | yes | `text`* | | `text` | full | yes | `text`* | | `profile-circle` | full | – | `name`, `about`, `imageUrl` | | `profile-square` | full | – | `name`, `about`, `imageUrl` | | `social-icons` | full | – | `icons`* — array of `{ id, platform, url }` | | `profile-cta` | full | – | `label`*, `url` — **singleton** | | `nav` | full | yes | – **singleton**, tabs built from headers with `showInNav` | | `spacer` | full | – | – | | `form` | full | – | `preset`*, `fields`*, `title` | | `booking` | half, full | yes | `bookingUnitId`* — everything else server-filled | | `product` | half, full | yes | `productId`* — everything else server-filled | | `map` | half, full | yes | `address`*, `title`, `hours` | `*` = required. `profile-cta` and `nav` are **singletons** — at most one of each per page. ### Card kinds `card` needs a `kind`, and each kind requires a different field: - `"showcase"` — shows something off. Optional `ctaUrl` makes the whole card a link. - `"for-sale"` — requires `price`, free text (`"₹2,400"`, `"From $40"`). Opens a detail view. - `"affiliate-link"` — requires `ctaUrl`, the affiliate destination. - `"promo-code"` — requires `code`. Tapping copies it. Optional on all kinds: `mediaUrls` (array), `description`, `ctaLabel`, `aspect: "portrait"` for 4:5 images. **`card` is for things sold elsewhere.** For something sold *on* the page through Stripe, use a `product` block. ### Form presets `form` needs `preset`: `"lead"`, `"feedback"`, or `"form"`, plus a `fields` array of `{ id, label, type, required }`. Submissions land in the owner's dashboard. ### Social icons `icons` is an array of `{ id, platform, url }` where `id` is a UUID you generate and `platform` is a known network. Keep it to a handful. ## Products and bookings are read-only `product` and `booking` blocks **reference** records the owner created in their dashboard. You place them; you cannot create them or change what they cost. Call `GET /api/v1/catalog`, then send only the reference: ```json { "type": "product", "size": "half", "productId": "8f2c…" } { "type": "booking", "size": "full", "bookingUnitId": "1a9e…" } ``` The server fills `title`, `priceText`, `mediaUrls`, `description`, and buyability from the record. **Anything you send for those fields is overwritten** — never invent a price. A card advertising the wrong number is worse than no card at all. If the user asks you to create a product or change a price, tell them to do it in the dashboard under Products or Bookings; then you can place it. ## Images Send a public `https://` image URL in `mediaUrls`, `imageUrl`, or `iconUrl` and the server fetches and stores it. Paths already starting with `/uploads/` are stored — **send those back unchanged**. A URL that can't be fetched, isn't an image, is too large, or resolves to a private address returns `422 INVALID_IMAGE`. ## Theme Optional `theme` on `PUT`: ```json { "roundedness": "soft", "brandColor": "#d7494c", "textColor": "#211f1b", "background": { "type": "color", "color": "#faf7f0" } } ``` - `roundedness`: `"flat" | "soft" | "round"` - `background.type`: `"color"` (with `color`), `"gradient"` (with `from`, `to`, optional `angle`), or `"image"` (with `imageUrl`) - Colors are 3- or 6-digit hex ## Page limit A page holds at most **100 blocks**. Over that the write is rejected with the reason and a support address. Keepp is deliberately one page — if someone wants 150 blocks, the better answer is usually a shorter, better-ordered page. ## Building a good page — judgment The API will happily let you build a bad page. These are the calls worth making well. **Lead with identity.** A `profile-circle` (or `profile-square`) first, then `social-icons`. A visitor should know whose page this is before they scroll. **Link vs preview-link vs card.** A `link` is a plain button — right for most destinations, and ten of them read cleanly. A `preview-link` adds an image, worth it for the two or three things you most want clicked. A `card` is for *merchandise* — something with a price, a code, or a look. Don't turn every link into a card: a wall of cards reads as an ad, and the ones that matter stop standing out. **Prefer `product` when money changes hands on the page.** A `for-sale` card sends people elsewhere; a `product` block takes payment right there. If the owner has products in their catalog, use them. **Use `header` to break up anything long.** Past six or seven blocks a page needs sections. Set `showInNav: true` on the two or three headers that are real destinations and add one `nav` block for working tabs. Don't mark every header — four tabs is a menu, ten is a mess. **Pair your `half` blocks.** Two halves sit side by side. A lone half leaves a gap unless you `align` it; `center` usually looks deliberate. **A form beats a link to a form.** If the goal is capturing interest, a `form` block on the page converts better than a `link` to a Google Form. **Ask before deleting.** Removing a block loses its content and its share link. Confirm first, and never remove blocks the user didn't mention. **Say what you changed.** After a `PUT`, describe it plainly — "added a product block for the ceramic mug under your Shop header" — and give them the page URL from `GET`. ## Errors Success: `{ "ok": true, "data": { … } }`. Error: `{ "ok": false, "error": { "code": "…", "message": "…" } }`. | Code | HTTP | Meaning | |---|---|---| | `NO_API_TOKEN` / `INVALID_API_TOKEN` | 401 | Missing, revoked, or wrong key. Ask the user for a valid one. | | `PLAN_REQUIRED` | 403 | The account isn't on Pro. | | `RATE_LIMITED` | 429 | Over 60 requests/minute. Slow down and retry. | | `INVALID_INPUT` | 400 | A block is malformed, or the page broke a rule (block cap, two singletons). | | `INVALID_IMAGE` | 422 | An image URL couldn't be fetched or processed. | | `NOT_FOUND` | 404 | A `productId` or `bookingUnitId` doesn't belong to this account. | | `VERSION_CONFLICT` | 409 | You sent a `version` and the page moved. Re-`GET` and reapply. | Errors caused by one block carry **`blockIndex`** — its position in the array you sent: ```json { "ok": false, "error": { "code": "INVALID_INPUT", "message": "Card price is required.", "blockIndex": 3 } } ``` Fix that block and resend the whole array. A rejected `PUT` changes nothing, so the page is exactly as it was. ## More context - Developer docs: https://keepp.link/developers - Machine index: https://keepp.link/llms.txt - Managing your page with an AI agent: https://keepp.link/blog/how-to/14-managing-your-page-with-an-ai-agent