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
- Sign in at https://homu.to/login.
- Open Dashboard → Account → API & AI and create a key. It looks like
homu_sk_…and is shown once. - 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
claude mcp add --transport http homu https://homu.to/api/mcp --header "Authorization: Bearer homu_sk_YOUR_KEY"Cursor / Windsurf / VS Code (mcp.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
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
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
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
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 paddingspaceAbove: BlockSpacingspaceBelow: BlockSpacingradius: "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 namedescription: string? — short biolocation: string? — city / place linelogoUrl: string? — https avatar imageheroImageUrl: string? — https wide bannerheroLayout: 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?
{
"heroImageUrl": "/demo/cafe-cover.jpg",
"logoUrl": "/demo/cafe-logo.jpg",
"title": "homu 데모 카페",
"description": "스캔 한 번으로 메뉴를 확인하세요. 대시보드에서 실시간 업데이트됩니다."
}TEXT
Free text. body supports bold, italic, label, lines starting with "- " as bullets, blank line = new paragraph.
title: string?body: string (max 5000 chars)size: "sm" | "md" | "lg"?
{
"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)
{
"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"?
{
"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?
{
"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, KRWshowAddButton: boolean?imageRadius: MenuImageRadius?imageAspect: MenuImageAspect?showDividers: boolean?addButtonAppearance: MenuAddButtonAppearance?addButtonShape: MenuAddButtonShape?gridCardStyle: MenuGridCardStyle?
{
"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 }[]
{
"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 URLtitle: string?autoplay: boolean?mute: boolean?loop: boolean?startSeconds: number?aspectRatio: YoutubeAspectRatio?
{}TWITCH
Embedded Twitch channel / video / clip.
mediaUrl: string — Twitch URLtitle: string?autoplay: boolean?muted: boolean?aspectRatio: YoutubeAspectRatio?
{}GOOGLE_MAPS
Embedded Google Map for a place or address.
query: string — address or place nametitle: string?zoom: number?mapType: GoogleMapsMapType?heightPx: number?mode: GoogleMapsEmbedMode?origin: string? (directions mode)
{}FEEDBACK (max one per page)
Visitor feedback form (messages arrive in the dashboard).
title: stringdescription: string?submitLabel: string?showContainer: boolean?containerStyle: FeedbackContainerStyle?buttonAppearance: FeedbackButtonAppearance?buttonShape: FeedbackButtonShape?
{
"title": "방문 후기를 남겨주세요",
"description": "메뉴나 서비스에 대한 의견을 들려주세요.",
"submitLabel": "Feedback"
}TIP_BOARD (max one per page)
Accept tips (Stripe / PayPal must be connected in the dashboard).
title: stringdescription: string?suggestedAmounts: number[]?minAmount: number?maxAmount: number?currencyCode: string?submitLabel: string?thankYouMessage: string?
{}FUNDING (max one per page)
Crowdfunding goal with progress bar.
title: stringdescription: string?imageUrl: string?goalAmount: numbercurrencyCode: string?suggestedAmounts: number[]?layout: FundingBlockLayout?showProgress: boolean?showContributorList: boolean?
{}BOARD
Posts board (posts themselves are managed in the dashboard, not in block content).
title: stringdescription: string?layout: BoardLayout?displayCount: number?writePermission: "owner" | "both"?commentsEnabled: boolean?showAuthor: boolean?showDate: boolean?frameStyle: BoardFrameStyle?
{}BOOKING (max one per page)
Appointment booking with time slots and services.
title: stringdescription: string?slotUnit: BookingSlotUnitslotDuration: numberopenDaysOfWeek: number[]? (0 = Sunday)dailyHours: { start: "HH:MM", end: "HH:MM" }?services: { id: string, name: string, priceMajor?: number }[]prepaymentEnabled: boolean?submitLabel: string?successMessage: string?
{}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:
[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.
