# homu API & MCP homu (https://homu.to) builds one-page "link in bio" sites: a profile header followed by blocks (text, images, links, menus/shops with checkout, galleries, video, maps, booking, tips, funding, boards). Every page can be read and edited through this API — by scripts, or by the owner's own AI assistant (Claude, ChatGPT, Cursor, Gemini…) through the MCP server. Base URL: `https://homu.to/api/v1` · MCP: `https://homu.to/api/mcp` · OpenAPI: `https://homu.to/openapi.json` · AI summary: `https://homu.to/llms.txt` ## 1. Get an API key 1. Sign in at https://homu.to/login. 2. Open **Dashboard → Account → API & AI** and create a key. It looks like `homu_sk_…` and is shown once. 3. Send it on every request: `Authorization: Bearer homu_sk_…` A key can do everything its owner can do with their pages. Revoke it in the same place at any time. Limits: 120 requests per minute per key, 10 active keys per account. ## 2. Connect your AI (MCP) The MCP server exposes the API as tools (list_pages, get_page, get_block_types, list_fonts, update_page, add_block, update_block, delete_block, replace_blocks). **Claude Code** ```bash claude mcp add --transport http homu https://homu.to/api/mcp --header "Authorization: Bearer homu_sk_YOUR_KEY" ``` **Cursor / Windsurf / VS Code (mcp.json)** ```json { "mcpServers": { "homu": { "url": "https://homu.to/api/mcp", "headers": { "Authorization": "Bearer homu_sk_YOUR_KEY" } } } } ``` **Claude.ai, ChatGPT and other clients that only accept a URL:** use `https://homu.to/api/mcp?key=homu_sk_YOUR_KEY`. Treat that URL like a password. Then just ask, for example: *"Make my homu page look like a cozy café: warm cream background, serif font, a short welcome text under my profile, and a photo block."* **No MCP?** Paste this into any AI chat: *"Read https://homu.to/llms-full.txt and use my homu API key to edit my page."* ## 3. Endpoints All responses are JSON. Errors look like `{ "error": { "code": "invalid_blocks", "message": "…" } }` with a 4xx/5xx status. | Method | Path | What it does | | --- | --- | --- | | GET | /api/v1/me | The key's account | | GET | /api/v1/pages | Your pages (id, title, publicUrl) | | GET | /api/v1/pages/{pageId} | Page, appearance and all blocks | | PATCH | /api/v1/pages/{pageId} | Change title, description, appearance | | PUT | /api/v1/pages/{pageId}/blocks | Replace all blocks | | POST | /api/v1/pages/{pageId}/blocks | Add one block | | PATCH | /api/v1/pages/{pageId}/blocks/{blockId} | Update one block (content, style, visible, position) | | DELETE | /api/v1/pages/{pageId}/blocks/{blockId} | Delete one block | | POST | /api/v1/pages/{pageId}/images | Upload an image (multipart field "file") → { url } | | GET | /api/v1/block-types | Block reference with examples (no key needed) | | GET | /api/v1/fonts | Font ids and button styles (no key needed) | Every write returns the updated page (`{ "page": … }`) and the public page updates immediately. ### Read your page ```bash curl https://homu.to/api/v1/pages -H "Authorization: Bearer $HOMU_KEY" curl https://homu.to/api/v1/pages/PAGE_ID -H "Authorization: Bearer $HOMU_KEY" ``` ### Change the design ```bash curl -X PATCH https://homu.to/api/v1/pages/PAGE_ID \ -H "Authorization: Bearer $HOMU_KEY" -H "Content-Type: application/json" \ -d '{ "title": "Sunny Café", "appearance": { "backgroundColor": "#fbf6ee", "primaryColor": "#5b3a29", "font": "playfair", "buttonStyle": "ROUNDED_FULL", "dynamicBackground": { "preset": "radial_corner", "baseColor": "#fbf6ee", "accentColor": "#f2c9a0", "intensity": 55 } } }' ``` `appearance` fields: `backgroundColor` and `primaryColor` (#rrggbb), `buttonStyle` (SQUARE, ROUNDED_XS, ROUNDED_SM, ROUNDED_MD, ROUNDED_FULL), `font` (an id from GET /api/v1/fonts), `dynamicBackground` (`{ preset, baseColor, accentColor, intensity 0–100, angle }` with preset none, linear_down, linear_up, linear_diagonal, linear_side, radial_center, radial_corner, mesh_dual or vignette; null turns it off), `customCss` (see section 5). ### Add a block ```bash curl -X POST https://homu.to/api/v1/pages/PAGE_ID/blocks \ -H "Authorization: Bearer $HOMU_KEY" -H "Content-Type: application/json" \ -d '{ "type": "TEXT", "position": 1, "content": { "title": "Welcome", "body": "Fresh coffee **every morning**.\n\n- Open 8–18\n- [Directions](https://maps.google.com)" }, "style": { "background": "surface", "padding": "md", "align": "center" } }' ``` `position` is the index in the page (the PROFILE block is always 0; omit to append). ### Update or move a block ```bash curl -X PATCH https://homu.to/api/v1/pages/PAGE_ID/blocks/BLOCK_ID \ -H "Authorization: Bearer $HOMU_KEY" -H "Content-Type: application/json" \ -d '{ "style": { "background": "accent", "radius": "lg", "shadow": "soft" }, "position": 2 }' ``` `content` replaces the whole content object, so read the block first and send it back with your changes. `style` replaces the block's whole `_style` (include every style field you want to keep); `"style": null` resets it. ### Replace everything at once `PUT /api/v1/pages/{pageId}/blocks` with `{ "blocks": [ { "id"?, "type", "content", "style"?, "visible"? }, … ] }`. Order = array order. Keep the ids of blocks you keep; leave `id` out for new blocks; blocks you omit are deleted. The PROFILE block must stay first. ## 4. Blocks Each block is `{ id, type, order, visible, content }`. Read GET /api/v1/block-types for the full machine-readable reference (including allowed enum values). ### Block style (`content._style`, on every block) Optional on every block: per-block design. Omit fields to follow the page theme. Set content._style = null/omit to reset. - `align`: "left" | "center" | "right" - `background`: "none" | "surface" | "accent" | "#rrggbb" - `textColor`: "#rrggbb" - `padding`: BlockSpacing — inner padding - `spaceAbove`: BlockSpacing - `spaceBelow`: BlockSpacing - `radius`: "none" | "sm" | "md" | "lg" - `shadow`: "none" | "soft" | "strong" - `hideOn`: "mobile" | "desktop" BlockSpacing = "none" | "sm" | "md" | "lg". ### PROFILE (max one per page) Page header: name, photo, bio, social icons. Exactly one per page, always first. - `title`: string — display name - `description`: string? — short bio - `location`: string? — city / place line - `logoUrl`: string? — https avatar image - `heroImageUrl`: string? — https wide banner - `heroLayout`: ProfileHeroLayout? - `socialLinks`: { id: string, platform: ProfileSocialPlatform, url: string, label?: string }[]? - `avatarSize`: ProfileAvatarSize? - `avatarShape`: ProfileAvatarShape? - `socialIconSize`: ProfileSocialIconSize? - `socialIconShape`: ProfileSocialIconShape? - `socialIconAppearance`: ProfileSocialIconAppearance? - `socialIconColorMode`: ProfileSocialColorMode? - `socialIconColor`: #rrggbb? (when socialIconColorMode is custom) - `socialIconGap`: ProfileSocialIconGap? - `showSubscribeButton`: boolean? ```json { "heroImageUrl": "/demo/cafe-cover.jpg", "logoUrl": "/demo/cafe-logo.jpg", "title": "homu 데모 카페", "description": "스캔 한 번으로 메뉴를 확인하세요. 대시보드에서 실시간 업데이트됩니다." } ``` ### TEXT Free text. body supports **bold**, *italic*, [label](https://…), lines starting with "- " as bullets, blank line = new paragraph. - `title`: string? - `body`: string (max 5000 chars) - `size`: "sm" | "md" | "lg"? ```json { "title": "About us", "body": "Fresh coffee **every morning**.\n\n- Open 8–18\n- [Directions](https://maps.google.com)", "size": "md" } ``` ### IMAGE One image or banner, optionally linked. - `imageUrl`: string — https URL (or upload via POST /api/v1/pages/{pageId}/images) - `alt`: string? - `caption`: string? - `linkUrl`: string? — https / mailto: / tel: - `aspect`: ImageBlockAspect? - `fit`: "cover" | "contain"? (when aspect is not auto) ```json { "imageUrl": "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085", "alt": "Latte art", "caption": "Our signature latte", "aspect": "4:3", "fit": "cover" } ``` ### DIVIDER A line, dots or empty space between blocks. - `variant`: "line" | "dots" | "space" - `size`: "sm" | "md" | "lg"? ```json { "variant": "line", "size": "md" } ``` ### LINKS Buttons / cards that open a URL or copy text. - `links`: { id: string, label: string, url: string, action?: LinkAction, copyText?: string, icon?: string (emoji), showFavicon?: boolean, imageUrl?: string, appearance?: LinkAppearance, imagePlacement?: LinkImagePlacement, shape?: LinkShape, size?: LinkSize, span?: 1 | 2 }[] - `layout`: LinksBlockLayout? - `columns`: 1 | 2? ```json { "links": [ { "id": "cafe-l1", "label": "테이블 예약", "url": "https://example.com/reserve", "icon": "📅", "showFavicon": false }, { "id": "cafe-l2", "label": "포장 주문", "url": "https://example.com/pickup", "icon": "🥡", "showFavicon": false }, { "id": "cafe-l3", "label": "Instagram", "url": "https://instagram.com", "icon": "📷", "showFavicon": true } ] } ``` ### MENU_LIST Menu / shop items with prices, options and cart checkout. Names use LocalizedString = { [languageCode]: string }, e.g. { "en": "Latte", "ko": "라떼" }. - `items`: { id: string, name: LocalizedString, price: number, description?: LocalizedString, imageUrl?: string, options?: { id, name: LocalizedString, extraPrice: number }[], productKind?: MenuProductKind, digitalDownloadUrl?: string, soldOut?: boolean, useExternalLink?: boolean, externalUrl?: string }[] - `columns`: MenuListColumns? - `currencyCode`: string? — ISO 4217, e.g. USD, KRW - `showAddButton`: boolean? - `imageRadius`: MenuImageRadius? - `imageAspect`: MenuImageAspect? - `showDividers`: boolean? - `addButtonAppearance`: MenuAddButtonAppearance? - `addButtonShape`: MenuAddButtonShape? - `gridCardStyle`: MenuGridCardStyle? ```json { "columns": 2, "items": [ { "id": "cafe-f1", "name": { "ko": "시그니처 아메리카노", "en": "Signature Americano", "ja": "シグネチャーアメリカーノ" }, "price": 5500, "imageUrl": "/demo/cafe-americano.jpg", "description": { "ko": "하우스 블렌드 · 싱글 오리진 변경 가능", "en": "House blend · single-origin swap available", "ja": "ハウスブレンド · シングルオリジン変更可" }, "options": [ { "id": "cafe-f1-h", "name": { "ko": "핫", "en": "Hot", "ja": "ホット" }, "extraPrice": 0 }, { "id": "cafe-f1-i", "name": { "ko": "아이스", "en": "Iced", "ja": "アイス" }, "extraPrice": 500 } ] }, { "id": "cafe-f2", "name": { "ko": "말차 크림 라떼", "en": "Matcha Cream Latte", "ja": "抹茶クリームラテ" }, "price": 7200, "imageUrl": "/demo/cafe-matcha.jpg", "description": { "ko": "제철 말차 · 바닐라 크림", "en": "Seasonal matcha · vanilla cream cap", "ja": "季節の抹茶 · バニラクリーム" } }, { "id": "cafe-f3", "name": { "ko": "소금빵 & 버터", "en": "Salt Bread & Butter", "ja": "塩パン&バター" }, "price": 4800, "imageUrl": "/demo/cafe-croissant.jpg", "description": { "ko": "갓 구운 소금빵 · 프렌치 버터", "en": "Warm salt bread · French butter", " ``` ### GALLERY Photo gallery (up to 20 images). - `title`: string? - `layout`: GalleryLayout? - `images`: { id: string, imageUrl: string, caption?: string }[] ```json { "title": "Our space & plates", "layout": "grid", "images": [ { "id": "cafe-g1", "imageUrl": "/demo/cafe-cover.jpg", "caption": "Morning light in the bar" }, { "id": "cafe-g2", "imageUrl": "/demo/cafe-matcha.jpg", "caption": "Seasonal matcha drinks" }, { "id": "cafe-g3", "imageUrl": "/demo/cafe-croissant.jpg", "caption": "Fresh from the oven" }, { "id": "cafe-g4", "imageUrl": "/demo/cafe-americano.jpg", "caption": "Single-origin espresso" } ] } ``` ### YOUTUBE Embedded YouTube video. - `videoUrl`: string — any YouTube URL - `title`: string? - `autoplay`: boolean? - `mute`: boolean? - `loop`: boolean? - `startSeconds`: number? - `aspectRatio`: YoutubeAspectRatio? ```json {} ``` ### TWITCH Embedded Twitch channel / video / clip. - `mediaUrl`: string — Twitch URL - `title`: string? - `autoplay`: boolean? - `muted`: boolean? - `aspectRatio`: YoutubeAspectRatio? ```json {} ``` ### GOOGLE_MAPS Embedded Google Map for a place or address. - `query`: string — address or place name - `title`: string? - `zoom`: number? - `mapType`: GoogleMapsMapType? - `heightPx`: number? - `mode`: GoogleMapsEmbedMode? - `origin`: string? (directions mode) ```json {} ``` ### FEEDBACK (max one per page) Visitor feedback form (messages arrive in the dashboard). - `title`: string - `description`: string? - `submitLabel`: string? - `showContainer`: boolean? - `containerStyle`: FeedbackContainerStyle? - `buttonAppearance`: FeedbackButtonAppearance? - `buttonShape`: FeedbackButtonShape? ```json { "title": "방문 후기를 남겨주세요", "description": "메뉴나 서비스에 대한 의견을 들려주세요.", "submitLabel": "Feedback" } ``` ### TIP_BOARD (max one per page) Accept tips (Stripe / PayPal must be connected in the dashboard). - `title`: string - `description`: string? - `suggestedAmounts`: number[]? - `minAmount`: number? - `maxAmount`: number? - `currencyCode`: string? - `submitLabel`: string? - `thankYouMessage`: string? ```json {} ``` ### FUNDING (max one per page) Crowdfunding goal with progress bar. - `title`: string - `description`: string? - `imageUrl`: string? - `goalAmount`: number - `currencyCode`: string? - `suggestedAmounts`: number[]? - `layout`: FundingBlockLayout? - `showProgress`: boolean? - `showContributorList`: boolean? ```json {} ``` ### BOARD Posts board (posts themselves are managed in the dashboard, not in block content). - `title`: string - `description`: string? - `layout`: BoardLayout? - `displayCount`: number? - `writePermission`: "owner" | "both"? - `commentsEnabled`: boolean? - `showAuthor`: boolean? - `showDate`: boolean? - `frameStyle`: BoardFrameStyle? ```json {} ``` ### BOOKING (max one per page) Appointment booking with time slots and services. - `title`: string - `description`: string? - `slotUnit`: BookingSlotUnit - `slotDuration`: number - `openDaysOfWeek`: number[]? (0 = Sunday) - `dailyHours`: { start: "HH:MM", end: "HH:MM" }? - `services`: { id: string, name: string, priceMajor?: number }[] - `prepaymentEnabled`: boolean? - `submitLabel`: string? - `successMessage`: string? ```json {} ``` ## 5. Custom CSS `appearance.customCss` styles anything on the public page. It is nested under the page root, so selectors only affect that page. Each block is wrapped in an element with `data-block-type` and `data-block-id`: ```css [data-block-type="LINKS"] a { letter-spacing: 0.02em; text-transform: uppercase; } [data-block-id="BLOCK_ID"] h2 { font-size: 2rem; } @media (min-width: 640px) { [data-block-type="IMAGE"] img { border-radius: 32px; } } @keyframes float { from { transform: translateY(0) } to { transform: translateY(-4px) } } ``` Not allowed (rejected with 400): `url()`, `@import`, `@font-face`, `<`, backslashes, scripting tricks. Max 20,000 characters. Pick fonts with `appearance.font` instead. ## 6. Tips for AI agents - Start with list_pages and get_page; reuse existing content and ids. - Prefer page-level appearance first, then per-block style, then customCss for anything else. - Check colors for contrast (text vs. background). - Images must be https URLs. To use a local file, upload it with POST /api/v1/pages/{pageId}/images. - Payments, bookings and tips need the owner to connect Stripe/PayPal in the dashboard; blocks can still be added.