Keepp Agent API
Build and manage your Keepp page programmatically — every block, the layout, and the theme. Available on Pro. Generate a key from your dashboard under AI Agent.
What this API manages
Your whole page: links, cards, profile, socials, headers, text, forms, maps, YouTube videos, menus, tickers, scheduling, and the theme. Products and bookable units are read-only — the API can place them on your page and arrange them, but it can’t create one or change a price. Those stay in your dashboard.
Authentication
Send your key as a bearer token. Base URL https://api.keepp.link.
Authorization: Bearer keepp_live_…
Endpoints
GET /api/v1/pageRead your whole page — version, url, publishedAt, blocks, theme.
PUT /api/v1/pageReplace your whole page. Any block you leave out is removed.
GET /api/v1/catalogList the products and bookable units your blocks can reference.
PUT replaces — read first
PUT is not a patch. Always GET the page, modify the array you received, and send all of it back. If you get 25 blocks and add one, send 26. The response returns blockCount before and after so you can confirm nothing was dropped.
A page is a list of blocks
Blocks sit on a two-column grid. A full block takes a row; two half blocks pair side-by-side. Array order is the order on the page.
PUT https://api.keepp.link/api/v1/page
{
"version": 12,
"theme": { "roundedness": "soft", "brandColor": "#d7494c" },
"blocks": [
{ "id": "2f9c…", "type": "profile-circle", "size": "full",
"name": "Studio Marlow", "about": "Hand-thrown ceramics." },
{ "id": "7a41…", "type": "header", "size": "full", "text": "Shop", "showInNav": true },
{ "type": "product", "size": "half", "productId": "8f2c…" },
{ "type": "card", "size": "half", "kind": "promo-code",
"title": "20% off your first order", "code": "FIRST20" }
]
}Block envelope
| type | enum | The block type — see the table below. |
| id | string? | UUID. Keep the ids you received; omit on a new block and one is minted. Ids back your per-block share links. |
| size | enum? | "full" or "half". Most types only allow "full"; defaults per type. |
| align | enum? | "left" | "center" | "right", on the types that support it. Useful for a lone half block. |
| style | object? | { bg, text } hex overrides. Dropped on types that can't render them. |
| shape | enum? | "pill" | "card", on the types that support it. Unlike style, a shape a type can't render is rejected, not dropped — leave it out entirely on the others. |
| schedule | object? | { from, until, tz } — show the block only inside a window. Accepted on every type. |
Scheduling
Any block can be given a window, and it only appears inside it:
"schedule": { "from": "2026-08-01T09:00", "until": "2026-08-14T23:59", "tz": "Asia/Kolkata" }| tz | string* | An IANA zone — “Europe/London”, not “GMT+1”. |
| from | string? | Local wall-clock YYYY-MM-DDTHH:mm — no seconds, no offset, no Z. |
| until | string? | Same format, and must be after from. |
At least one of from / until is required. Times are deliberately local rather than UTC, so 9am Friday stays 9am across a daylight-saving change. The window is evaluated server-side when the page is read, so a block that hasn’t started isn’t in the HTML at all — it can’t be found in view-source. Scheduled blocks still count toward the 100-block limit.
Block types
| link | half, full | shape pill|card (default pill) — title*, url*, imageUrl* (card only), icon / iconUrl (pill only) |
| card | half, full | shape card|pill (default card) — kind*, title* (optional for showcase only), mediaUrls, description, additionalInfo, ctaLabel, aspect wide|square|tall, icon — plus a per-kind field (below) |
| header | full | text*, showInNav — set showInNav to build nav tabs. variant: "plain" | "eyebrow" | "divider" |
| subheader | full | text*, variant: "plain" | "eyebrow" | "divider" |
| text | full | text* |
| profile-circle | full | name, about, imageUrl |
| profile-square | full | name, about, imageUrl |
| social-icons | full | icons* — [{ id, platform, url }] |
| profile-cta | full | label*, url — singleton |
| nav | full | No fields — singleton. Tabs come from headers with showInNav. |
| spacer | full | No fields. |
| form | full | shape pill|card (default pill) — preset* ("lead" | "feedback" | "form"), title*, fields*, description (shown on the card), triggerLabel, submitLabel, successMessage, verification |
| booking | half, full | shape card|pill (default card) — bookingUnitId*, aspect wide|square|tall — all other display fields are server-filled |
| product | half, full | shape card|pill (default card) — productId*, aspect wide|square|tall — all other display fields are server-filled |
| map | half, full | shape card only — embedSrc*, title, address, hours |
| youtube | half, full | shape card only — videoId*, title, description |
| ticker | full | text* (max 300), direction rtl|ltr, speed slow|normal|fast, pauseOnHover |
| menu | half, full | shape pill|card (default pill) — title*, subtitle, imageUrl, triggerLabel, items[] |
A menu item is { name, description, price, tags[] }. Keep descriptions to 50 words, up to 100 items, and no more than 10 distinct tags per menu.
* required. profile-cta and nav are singletons — one each per page.
Form fields
A form takes a fields array of { id, label, type, required }, where id is a UUID you generate. Submissions land in your dashboard.
| type | enum* | short_text, long_text, email, phone, single_select, multi_select, product_interest, social_handles, rating |
| options | array? | Required by the select types. |
| isIdentifier | bool | Exactly one field must set this, and only an email or phone field may — it's how a submission is attributed to a person. |
| verification | object? | On the block: { email: true } makes the submitter confirm their address before the submission counts. |
Sending no identifier, or two, rejects the whole PUT. It’s the most common reason a hand-built form block fails.
Card kinds
| showcase | — | Shows something off. The only kind where title isn't required. Opens the detail dialog on tap; optional ctaUrl adds a CTA button on the card face. |
| for-sale | price* | Free text price — "₹2,400", "From $40". Opens the detail dialog on tap. |
| affiliate-link | ctaUrl* | The affiliate destination, used for the card's CTA button. Opens the detail dialog on tap. |
| promo-code | code* | The code's chip copies it directly when tapped; tapping the rest of the card opens the detail dialog, where the code is also available. |
Use a card for something sold elsewhere. For something bought on your page through Stripe, use a product block.
Products & bookings
Call GET /api/v1/catalog and send only the reference — { "type": "product", "productId": "…" }. The server fills title, price, images and buyability from the record, so any values you send for those are overwritten. This is deliberate: a key can never publish a price the seller didn’t set.
Images
Put a public https image URL in mediaUrls, imageUrl, or iconUrl and it’s fetched and stored for you. Paths already starting with /uploads/ are yours already — send them back unchanged.
Theme
| roundedness | enum? | "flat" | "soft" | "round". |
| brandColor | string? | 3- or 6-digit hex. |
| textColor | string? | 3- or 6-digit hex. |
| fontPairing | enum? | "editorial" | "bold" | "classic" | "modern" | "soft" | "handwritten" | "minimal" — sets the page's display/body font pair. |
| background | object? | { type: "color" | "gradient" | "image" } with color, or from/to/angle, or imageUrl + optional veil/position. |
| background.veil | enum? | "none" | "light" | "strong" — scrims an image background so text stays readable. |
| background.position | enum? | "left top" … "right bottom" — the nine CSS keyword positions. |
Unrecognised theme keys and invalid values are silently dropped, not rejected — a typo in fontPairing leaves your fonts unchanged with no error. Re-read the page if you need to confirm a theme change landed.
Limits
- Up to 100 blocks per page.
- Images up to 10 MB each (JPEG/PNG/WebP/GIF), from a direct public
httpsURL. - 60 requests per minute per key.
- Writes publish to your live page immediately — there is no draft state.
- A full page is roughly 6k tokens. If you’re generating one with an LLM, raise its output limit — a truncated body fails as malformed JSON.
Errors
401 NO_API_TOKEN / INVALID_API_TOKEN— missing or revoked key.403 PLAN_REQUIRED— the key’s business is not on Pro.400 INVALID_INPUT— a block is malformed, or the page broke a rule.422 INVALID_IMAGE— an image URL couldn’t be fetched, was the wrong type/size, redirected, or resolved to a non-public address.404 NOT_FOUND— aproductIdorbookingUnitIdisn’t yours.409 VERSION_CONFLICT— you sent aversionand the page moved. Re-read and reapply.429 RATE_LIMITED— more than 60 requests in a minute.
Errors caused by one block include blockIndex, its position in the array you sent. A rejected write changes nothing, so your page is exactly as it was.
Using an LLM? Hand it the Keepp skill or the llms.txt index — both teach it how to structure a good page, not just call the API.