Skip to content

REST endpoints

Base URL: https://api.grails.design. Every request carries Authorization: Bearer <your key>.

The machine-readable version of this page is at /openapi.json — OpenAPI 3.1, no credential required.

Conventions

Pagination is cursor-based

GET /v1/components?limit=20
→ { "data": [...], "pagination": { "next_cursor": "…", "has_more": true, "limit": 20 } }
GET /v1/components?cursor=<next_cursor>

There is no page or offset. A cursor encodes where you were in the current sort, so a sync that adds rows while you walk cannot make you skip or repeat one.

limit is 1–100, default 20. Above 100 truncates silently; below 1 or non-numeric is a 400.

Sparse fieldsets

fields restricts the columns returned. id is always present and cannot be excluded.

GET /v1/components?fields=name,sync_status,variant_count

project_id is never a parameter

Your key determines the project. A project_id in the query string, the path or a body is ignored — not honoured, not an error.


Components

GET /v1/components

Scope: read:library

List and search. Returns data, pagination, filters_applied and search_mode_used.

Parameter
qMatched against component names
search_modeexact, semantic, hybrid
sync_statusRepeatable or comma-separated. Deleted-in-Figma components are excluded unless asked for
publish_statusCURRENT, CHANGED, UNPUBLISHED
component_depth · component_depth_gte · component_depth_lteDepth 1 is an atom
typeCOMPONENT or COMPONENT_SET
parent_idOnly the variants of this set
has_docs · has_spec · has_driftTri-state — omit for “either”
sort_byname, created_at, component_depth, variant_count, chi_score
sort_dirasc or desc

search_mode accepts semantic and hybrid today and serves them lexically — the response says what actually ran, in search_mode_used. That way clients adopting them now do not break on the day the capability lands.

GET /v1/components/:id

Scope: read:library

One component with chi_status, has_docs, has_spec, has_drift. A component deleted in Figma is still readable by id — sync_status tells you.

GET /v1/components/:id/spec

Scope: read:library

The latest spec, plus spec_schema_version.

Read the version before interpreting the spec. Multiple schema versions are normally live and they share top-level keys. See Reading responses.

GET /v1/components/:id/build-order

Scope: read:library

Dependencies first, for building something composed. One component per call: the order is computed for the request, not read from a stored column.

Read status first, and it is one of two: computed (it has dependencies, ordered in ordered_components) or trivial (it depends on nothing, so the order is the component alone). ordered_components is never null and never [] — a component that exists always has an order, at minimum itself, and one that does not is a 404.

Each entry is { position, component_id, name, component_depth, is_leaf, grails_sync_status }, with the component you asked for last. Iterate in order and nothing arrives before its own dependencies.

A component deleted in Figma is listed, in place, with grails_sync_status: "deleted_in_figma". Read that field before building a step. component_depth may be null (the sync has not ranked it) and is never 0.

Dependencies that exist in Figma but not in Grails are not listed. An unsynced library component cannot be built from what this project knows, so including it would put an unbuildable step inside a build order.

POST /v1/components/specs:batchGet

Scope: read:library

{ "component_ids": ["uuid-1", "uuid-2"] }

Up to 50 ids. More returns GRAILS_BATCH_LIMIT_EXCEEDED.

POST for a read because 50 UUIDs do not fit a URL safely — intermediaries truncate long ones without saying so.

Check missing in the response before matching results to your request. Each entry is { id, reason }: no_spec means the component is in this project and only its spec is absent (re-sync from the Figma plugin, or filter with has_spec=true), while not_found means no component with that id is in this project, so retrying will not help. Another project’s id reads as not_found, the same as one that never existed.

POST /v1/components:validate

Scope: read:library

{ "component_ids": ["uuid-1"] }

Returns { valid, issues[], checked }. Issue types are missing_variant, no_spec and not_a_variant_set; only the first two make valid false. spec.invalidVariantCombinations is ignored here: it is derived as cartesian − variants[], so subtracting it from that same difference would always leave the empty set.


Entities

GET /v1/entities:match

Scope: read:library

Find any documentable entity by term or concept. The eight types are exactly the ones the documentation endpoints accept, so anything that can be documented can be found here.

It replaces GET /v1/entities:search, which served the name engine alone under a flat response shape. Same filters, three engines, and a response that says which of them answered.

Parameter
queryRequired. A term, a name fragment, or a concept. The name engine matches a literal case-insensitive SUBSTRING (% and _ match themselves; no fuzzy matching, no stemming, no synonyms, so a typo finds nothing). The semantic and text engines match by meaning, but only over entities whose documentation is indexed
entity_typescomponent, token, style, token-collection, screen, flow, project, doc-page. Repeatable or comma-separated. Default: all eight
limit1–20 per group (that is, per entity type), default 5. Above 20 truncates
cursorFrom a previous next_cursor. Keep query and every filter identical while paging — a cursor replayed against a different question is rejected
has_docstrue / false. Omit for either — every match carries has_docs regardless. false necessarily excludes every semantic and text match
include_deletedtrue / false, default false. Only component, token, style and token-collection have a Figma sync lifecycle; for the other four this does nothing
resolved_typeTokens only: COLOR, FLOAT, STRING, BOOLEAN. With it set, tokens come from the name engine only
style_typeStyles only: TEXT, PAINT, EFFECT, GRID. Same caveat

Three engines, and the result says which matched

EngineOver whatFinds
semanticthe entity’s indexed documentation, by vector similaritymeaning — text inputInput Field
textthe same documentation, full-textthe words the documentation actually uses
namethe entity’s name, ILIKEthe name, and only the name — but over 100% of the catalogue

They are fused with Reciprocal Rank Fusion, which combines rankings, not scores: a cosine similarity and a text rank do not live on the same scale, and averaging them would require choosing a normalisation nothing in the data justifies.

Every match carries matched_by (a list), surfaces (how many engines found it) and signals, where each raw figure stays tagged with its engine and is never fused into one number.

There is no confidence

score is a fused reciprocal-rank sum. It is comparable within one response, meaningless across responses, and it supports no threshold — and neither does a cosine, which measures an angle, not a probability.

What answers should I choose, or ask again? is shape.verdict, per group:

  • one_stands_out — the top match beat the second on evidence you can re-derive from the same response: more engines found it (surfaces), its name matched in a stronger tier (signals.name.matched: exact > segment > prefix > substring), or its cosine led by at least 0.05.
  • flat_ambiguous — none of that held. The query was ambiguous: ask a narrower one instead of taking the first row.

The shape of the response

Results are grouped by entity type, because your next call depends on the type: a component takes getComponentSpec({ componentId }), a token takes include-values.

Each group carries truncated: { shown, total } — a cut list read as a complete one is how you conclude there are three of something when there are eleven — and each match carries excerpt, the fragment of documentation that matched. An excerpt of null means the hit came from the name alone.

Each match also carries context, whose meaning is per type — a token’s collection, a screen’s Figma node id, a doc page’s slug — and which is null when nothing in that table tells two same-named rows apart. sync_status is null for screen, flow, project and doc-page: the concept does not apply to them, which is not the same as unknown.

entity_types=project returns at most your own project. It is not a project directory: a key is scoped to one project and cannot see another.

Names, ids and a documentation excerpt — no token values, no bindings.


Tokens and styles

Both are served in three layers, and the reason is the same for each: serving the whole token tree costs on the order of 58,000 model tokens, and the job in front of you usually needs five values. So every layer publishes the price of the next one, in cost.*.est_tokens, plus the exact call that fetches it in cost.*.how.

All six routes take read:library, like every other read route. Tokens and styles had a scope of their own until September 2026, and it never separated anything a caller could act on: a component’s spec already names the tokens it binds to anyone who can read the component, so the scope only withheld the values.

GET /v1/tokens:describe · GET /v1/styles:describe

The map. Collections with their modes and token counts; style types with their counts. No names, no values — this layer exists so you can budget before asking.

GET /v1/tokens · GET /v1/styles

The index: ids and names, so you can pick the handful you need.

Parameter
collectionTokens only. A collection id from :describe, never a name
typeTokens: COLOR, FLOAT, STRING, BOOLEAN. Styles: TEXT, PAINT, EFFECT, GRID
name_prefixPrefix, not substring. Brand does not match Background/Brand/Default. Use /v1/entities:match to match anywhere
cursor, limit1–500, default 100

GET /v1/styles also returns figma_style_id — the raw S:…, key a component spec uses to reference the style. It is what makes the index joinable to a spec in either direction.

Page order is by internal id — stable, and not alphabetical. Names are not a safe cursor key here, so name_prefix is how you take a name-coherent slice.

GET /v1/tokens without a filter returns the first page plus the :describe map, not the whole index. GET /v1/styles without a filter attaches the per-type breakdown, so an empty type is visibly the data rather than a filter you forgot.

POST /v1/tokens:values

The values, per mode, with via — the alias chain walked to reach each one. Body: exactly one of ids (≤50) or collection, plus an optional mode.

A value is null only when unresolved says why. Colours come back twice: value is hex rounded to 8 bits per channel, rgba is exactly what Figma stored. Use rgba when precision matters.

POST /v1/styles:values

The full properties of a style. Body: exactly one of ids (≤50), figma_style_ids (≤50) or type.

properties is passed through exactly as Figma stored it, and its keys depend on type:

typeKeys you can expect
TEXTfontName ({family, style}), fontSize, lineHeight and letterSpacing (each {value, unit}), textCase, textDecoration, paragraphIndent, paragraphSpacing
PAINTpaints[] — entries are SOLID or a GRADIENT_* carrying gradientStops[]
EFFECTeffects[]
GRIDlayoutGrids[]

Read the keys rather than assuming them. The set varies with what the design file contained, and absent ones are not filled in.

bound_variables is the part people miss. It lists the properties driven by a design token rather than by the literal next to them. Where one is present, the value in properties is what that token was worth at the last sync — follow token_id into /v1/tokens:values for the live one. A token_id of null means no variable in this project carries that key.

Both are POST because each input array carries up to 50 values and a Figma key is 43 characters. Neither reads nor writes anything else.


Artifacts

GET /v1/components:driftSummary

Scope: read:library

Every component whose spec predates the minimum supported schema version.

truncated: true means drifting_count is a floor, not a total.


Entity graph

GET /v1/entities/{entity_type}/{entity_id}/graph

Scope: read:library

The inverse of a spec: what depends on this, before you change it. A spec tells you what a component uses; this tells you who would break.

The node is in the path, so the graph always hangs off one entity. entity_type is component, token, style or token-collection; resolve the id with /v1/entities:match rather than guessing it.

Edges carry their metadata, which is what makes them actionable, and both ends resolve to a name: a graph of bare uuids forces you to resolve them yourself.

edge_types narrows the result — it does not select the node.

direction defaults to both: with one node and no direction stated, returning only one side would hide half the graph without saying so. Ask inbound for the impact question — what uses THIS — and outbound for what this uses.

Pages are ordered and stable, so truncated: true comes with a next_cursor you pass back as cursor to walk the rest.

GET /v1/entities/{entity_type}/{entity_id}/related

Scope: read:library

The same node, walked instead of listed. /graph returns raw edges with their metadata for a single hop; this returns the entities reachable from the node, deduplicated, each with how far away it is and by which route.

Use /graph when you need to know where in a component a binding lives. Use this when you need the set of things connected to something.

Parameter
depth1–3, default 1. Above 3 it is rejected, not truncated: a silently lowered depth would read as the complete closure
edge_typesUSES_COMPONENT, BINDS_VARIABLE, USES_STYLE, STYLE_BINDS_VAR, ALIASES_VARIABLE. Narrows which relationships are followed; it does not select the node
directionoutbound = what this uses · inbound = what uses THIS, the impact question · both (default). It applies to every hop
limitEntities per page, 1–200, default 50. It bounds what is returned, not what is walked
cursorFrom a previous next_cursor

Every result carries hops — a number, not a “direct / indirect” label — and via, the route: one entry per edge crossed, naming the edge type, the direction, and (on every step but the last) the node it passed through. That is the difference between “this component binds the token” and “a style this component uses binds it”, and a label cannot express it.

truncated: true means the walk hit its node ceiling and related is a floor, not the set. next_cursor walks the rest of what was found.


Documentation

Every parameter is in the contract reference.

Two subjects, and the paths say which. /v1/entities/{type}/{id}/documentation is the documentation written about an entity — what a team said about a component, a token or a style, which lives attached to that thing and which nobody files anywhere. /v1/documentation/pages is a page: a standalone document with a title, a URL slug and a folder, which the team creates, renames, moves and deletes.

Both are made of the same blocks, and one writer writes both. What a page has and an entity does not is an identity of its own, and that is the whole of the difference.

Blocks on an entity

RouteScope
GET /v1/documentation/guideread:docsThe order of the steps. Start here.
GET /v1/documentation/block-typesread:docsWhat shapes exist, and where each may live ?section=criteria|shapes serves the authoring guide one piece at a time.
GET /v1/entities/{type}/{id}/documentationread:docsThe blocks of one entity, plus a mentions branch: what they reference, resolved
POST /v1/documentation:validateread:docsValidates without writing
PUT /v1/entities/{type}/{id}/documentationwrite:docs⚠️ Replaces ALL documentation of the entity
POST /v1/entities/{type}/{id}/documentationwrite:docsAdds at the end. Retrying duplicates
DELETE /v1/entities/{type}/{id}/documentation/{block_id}write:docsHard delete by id. No trash, no undo
POST /v1/entities/{type}/{id}/documentation:batchDeletewrite:docsSeveral at once, atomically

With {type} as doc-page, {id} accepts the page’s slug as well as its uuid, exactly as GET /v1/documentation/pages/{page} does. A doc page is the only kind of entity a readable name can address here; everything else takes a uuid or a Figma id. This is also how a page receives its content: a page is created empty, and its blocks go in through POST /v1/entities/doc-page/{id}/documentation.

Pages, and the folders they live in

RouteScope
GET /v1/documentation/pagesread:docsThe pages of this project — and in paths, the only aggregated view of which folders exist
GET /v1/documentation/pages/{page}read:docsOne page whole: title, slug, folder, position and its blocks. Takes the uuid or the slug
POST /v1/documentation/pageswrite:docsCreates an EMPTY page. Read the slug out of the response
PATCH /v1/documentation/pages/{page_id}write:docsRename, move, or edit the abstract or icon. Takes the uuid. An absent field and null are different requests
DELETE /v1/documentation/pages/{page_id}write:docs⚠️ Hard delete: the page, its blocks and its files. uuid only
POST /v1/documentation/paths:renamewrite:docsRenames a folder across every page carrying it. Answers { updated }
POST /v1/documentation/paths:clearwrite:docsUnfiles every page in a folder. Nothing is deleted. Answers { updated }

GET /v1/documentation/pages takes path (an exact folder, not a prefix), limit (1–100, default 20 — above the cap it truncates rather than failing) and cursor.


Uploads

An image or file block points at an object in storage. These are the routes that put one there. Nothing is visible until a block references it: an uploaded object nothing points at is an orphan, and the collector eventually clears it.

POST /v1/documentation/images

One call. Give exactly one of data — the image as a base64 string — or sourceUrl, an https URL on a short allow-list of hosts. Returns the r2_key to put in the block.

Terminal window
curl -X POST https://api.grails.design/v1/documentation/images \
-H "Authorization: Bearer $GRAILS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"entityType":"component","entityId":"<uuid>","contentType":"image/png","data":"iVBORw0KG..."}'

There is no confirm step, and that makes this path safer than the presigned one rather than less: the bytes arrive at the server, so they are measured there, and there is no window between signing and measuring.

POST /v1/documentation/uploads:presign and POST /v1/documentation/uploads:confirm

Three steps, for files and for images too large to send inline:

  1. :presign with kind, entityType, entityId, contentType and sizeBytesupload_url and r2_key;
  2. you PUT the bytes to upload_url with the same Content-Type;
  3. :confirm with the r2_key.

:confirm returns the measured size_bytes, which may differ from what you declared — a presigned URL signs the key and the content type but NOT the size, so sizeBytes at step 1 is a promise and this is where it becomes a fact.

Limits, and where each one comes from

imagefile
accepted typesPNG, JPEG, GIFPDF, DOCX, PPTX, XLSX, ODT, ODP, ODS
per-object cap3 MB — a limit of FORMAT, the same on every plana plan property; 10 MB today
team totalthe plan’s storage quota, reserved before the URL is signedsame

A refusal for lack of room answers 413 GRAILS_STORAGE_QUOTA_EXCEEDED and carries current_bytes, limit_bytes and pct_used in details.

After the upload

Write the block. For an image, an image block whose content.r2_key is what you got back; alt is required.

For a file, the text inside it is extracted afterwards so the document answers searches. That is the platform’s job and it does not qualify your upload: the confirm call is complete either way. Read the block later to see how it went — extraction.status is done, no-text (a scanned document: it finished and there was no text), unsupported or failed, and chars says how much came out. The extracted text itself is never returned: it feeds the search index, and serving it would add up to 30,000 characters to every documentation read.


Not built yet

Variables (the /v1/variables* and /v1/variable-collections* routes) and project metadata are designed but not implemented. Tokens and styles are built — see the section above. /openapi.json describes exactly what exists; if it is not in there, it does not answer.