HTML to Figma API
Two REST endpoints over the same engine: send raw HTML (or a URL) and either get back a clipboard payload to ⌘V into Figma, or stream a downloadable .fig file.
Overview
The copyto.design API exposes the same conversion engine that powers our web app and Figma paste flow. Pick one of two endpoints depending on whether you want to paste into Figma or open a file in Figma Desktop.
POST /v1/open/html_to_figmaPOST /v1/open/html_to_fig_file.fig fileIf you're wiring this into Claude, Claude Code, Codex, or Cursor, start with the Agents guide — it has copy-paste skill files, MCP configs, and tool schemas.
Authentication
Both endpoints are authenticated with an Open API key sent in the Authorization header as a bearer token. Issue and rotate keys from Settings → API keys. Keys are scoped to a single workspace and grant full conversion access on its quota — treat them like passwords.
Authorization: Bearer sk_8f3c9a2e4b1d5e6f7a8b9c0d
Content-Type: application/jsonKey prefixes
sk_/v1/open/* endpoints; counts against your workspace quota.Quickstart
Convert a small snippet end-to-end in three lines. The response body is an HTML string you write to the system clipboard as text/html — when you switch to Figma and press ⌘V, native nodes appear.
# html_to_figma → clipboard payload (HTML)
curl https://api.copyto.design/v1/open/html_to_figma \
-H "Authorization: Bearer $CTD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<section><h1>Hello</h1></section>",
"options": { "viewportWidth": 1440 }
}'const res = await fetch("https://api.copyto.design/v1/open/html_to_figma", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.CTD_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, options: { viewportWidth: 1440 } }),
});
const clipboard = await res.text();
await navigator.clipboard.write([
new ClipboardItem({ "text/html": new Blob([clipboard], { type: "text/html" }) }),
]);
// → switch to Figma, press ⌘Vimport os, requests
res = requests.post(
"https://api.copyto.design/v1/open/html_to_figma",
headers={"Authorization": f"Bearer {os.environ['CTD_API_KEY']}"},
json={"html": html_string, "options": {"viewportWidth": 1440}},
)
clipboard_html = res.text
# Then write `clipboard_html` to the system clipboard as text/html→ Want a downloadable .fig instead? Hit POST /v1/open/html_to_fig_file with the same body and pipe the binary response to a file.
/v1/open/html_to_figmaStableHTML to Figma clipboard
Renders the supplied HTML (or URL), maps the layout into a Figma node tree, and returns an HTML string carrying the clipboard payload. Write the body verbatim to the system clipboard as text/html — Figma's native paste handler picks it up and creates real frames / Auto Layout / text styles / components.
Request body
One of html or url is required.
htmlurl is not provided.urlhtml is not provided.baseUrlhtml.imageDataMapimageRef. Each value is base64 bytes; merged with images the engine collects during render.Example
curl https://api.copyto.design/v1/open/html_to_figma \
-H "Authorization: Bearer $CTD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://stripe.com/pricing",
"options": {
"viewportWidth": 1440,
"deviceScaleFactor": 2,
"waitUntil": "networkidle",
"timeoutMs": 20000
}
}'<meta charset='utf-8'><span data-buffer="<!--(figma)eyJ2IjoxLCJub2RlcyI6Wy4uLl19...(/figma)-->"></span>The response body is exactly the string shown above (one line). See Figma paste protocol for what each piece is and how Figma decodes it.
/v1/open/html_to_fig_fileStableHTML to .fig file
Same render pipeline as html_to_figma, but the response is a binary .fig file — application/octet-stream with Content-Disposition: attachment; filename="output.fig". Drop the file into Figma Desktop with File → Open (or upload to the web app).
Request body
Identical to /v1/open/html_to_figma — see the table above.
Response headers
Content-Typeapplication/octet-streamContent-Dispositionattachment; filename="output.fig"Content-LengthExample
curl https://api.copyto.design/v1/open/html_to_fig_file \
-H "Authorization: Bearer $CTD_API_KEY" \
-H "Content-Type: application/json" \
--output output.fig \
-d '{ "html": "<!doctype html><body><h1>hi</h1></body>",
"options": { "viewportWidth": 1440 } }'In a browser, fetch().blob() the response and trigger a download via URL.createObjectURL + an anchor with download="output.fig". The request itself is fine from any origin (CORS is enabled).
Render options
Both endpoints accept the same options object. Defaults are tuned for marketing pages and dashboards (1440-wide @ 1x). Reach for these knobs when the page needs JS hydration, a different breakpoint, or a longer timeout.
options.viewportWidth1440.options.viewportHeightoptions.deviceScaleFactor1.options.waitUntilload | domcontentloaded | networkidle. Default networkidle.options.waitForSelectoroptions.timeoutMs15000.imageDataMap
Optional dictionary of pre-fetched images: keys are the imageRef our engine emits for an <img> or CSS background; values are base64 bytes. Use it when the assets live behind auth, or when the source HTML references blob URLs the renderer can't reach.
{
"imageDataMap": {
"[email protected]": "iVBORw0KGgoAAAA...",
"logo.svg": "PHN2ZyB4bWxucz0i..."
}
}Figma paste protocol
The string returned by /v1/open/html_to_figma is Figma's clipboard hand-off format — a single <span> with a data-buffer attribute that wraps a base64-encoded clipboard payload between <!--(figma) ... (/figma)--> markers:
<meta charset='utf-8'><span data-buffer="<!--(figma)BASE64_PAYLOAD(/figma)-->"></span>Write it to the system clipboard as text/html (not plain text!). Figma's web client and desktop app both accept it on paste.
async function copyToFigma(html) {
const blob = new Blob([html], { type: "text/html" });
await navigator.clipboard.write([
new ClipboardItem({ "text/html": blob }),
]);
}navigator.clipboard.write requires a user gesture and a secure context. Trigger your copy from a click handler — Safari is strictest about this. For server-side flows, ship the user a .fig file via /v1/open/html_to_fig_file instead.
Errors
Errors come back as standard HTTP status codes with a JSON body. The code is stable and safe to switch on; the message is for humans.
{
"code": 400,
"message": "Either `html` or `url` is required"
}invalid_requestauth_errorquota_exhaustedendpoint_not_allowedrender_failedrate_limitedserver_errorRate limits
Limits are enforced per IP and per workspace. Hitting either yields a 429 with a Retry-After header — back off the indicated number of seconds before retrying.
Retry-After14429. Wait this many seconds.Concurrent renders are scarce — each conversion holds a full browser session. Serialize requests within a job and prefer bursts of small payloads over single huge ones.