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

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.

MethodPathWhat it does
GET/api/v1/meThe key's account
GET/api/v1/pagesYour 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}/blocksReplace all blocks
POST/api/v1/pages/{pageId}/blocksAdd 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}/imagesUpload an image (multipart field "file") → { url }
GET/api/v1/block-typesBlock reference with examples (no key needed)
GET/api/v1/fontsFont 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 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?
{
  "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"
}

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 / 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?
{
  "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",
        "

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 URL
  • title: string?
  • autoplay: boolean?
  • mute: boolean?
  • loop: boolean?
  • startSeconds: number?
  • aspectRatio: YoutubeAspectRatio?
{}

TWITCH

Embedded Twitch channel / video / clip.

  • mediaUrl: string — Twitch URL
  • title: string?
  • autoplay: boolean?
  • muted: boolean?
  • aspectRatio: YoutubeAspectRatio?
{}

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)
{}

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?
{
  "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?
{}

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?
{}

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?
{}

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?
{}

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.