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_countproject_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 | |
|---|---|
q | Matched against component names |
search_mode | exact, semantic, hybrid |
sync_status | Repeatable or comma-separated. Deleted-in-Figma components are excluded unless asked for |
publish_status | CURRENT, CHANGED, UNPUBLISHED |
component_depth · component_depth_gte · component_depth_lte | Depth 1 is an atom |
type | COMPONENT or COMPONENT_SET |
parent_id | Only the variants of this set |
has_docs · has_spec · has_drift | Tri-state — omit for “either” |
sort_by | name, created_at, component_depth, variant_count, chi_score |
sort_dir | asc 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 | |
|---|---|
query | Required. 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_types | component, token, style, token-collection, screen, flow, project, doc-page. Repeatable or comma-separated. Default: all eight |
limit | 1–20 per group (that is, per entity type), default 5. Above 20 truncates |
cursor | From a previous next_cursor. Keep query and every filter identical while paging — a cursor replayed against a different question is rejected |
has_docs | true / false. Omit for either — every match carries has_docs regardless. false necessarily excludes every semantic and text match |
include_deleted | true / false, default false. Only component, token, style and token-collection have a Figma sync lifecycle; for the other four this does nothing |
resolved_type | Tokens only: COLOR, FLOAT, STRING, BOOLEAN. With it set, tokens come from the name engine only |
style_type | Styles only: TEXT, PAINT, EFFECT, GRID. Same caveat |
Three engines, and the result says which matched
| Engine | Over what | Finds |
|---|---|---|
semantic | the entity’s indexed documentation, by vector similarity | meaning — text input → Input Field |
text | the same documentation, full-text | the words the documentation actually uses |
name | the entity’s name, ILIKE | the 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 | |
|---|---|
collection | Tokens only. A collection id from :describe, never a name |
type | Tokens: COLOR, FLOAT, STRING, BOOLEAN. Styles: TEXT, PAINT, EFFECT, GRID |
name_prefix | Prefix, not substring. Brand does not match Background/Brand/Default. Use /v1/entities:match to match anywhere |
cursor, limit | 1–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:
type | Keys you can expect |
|---|---|
TEXT | fontName ({family, style}), fontSize, lineHeight and letterSpacing (each {value, unit}), textCase, textDecoration, paragraphIndent, paragraphSpacing |
PAINT | paints[] — entries are SOLID or a GRADIENT_* carrying gradientStops[] |
EFFECT | effects[] |
GRID | layoutGrids[] |
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 | |
|---|---|
depth | 1–3, default 1. Above 3 it is rejected, not truncated: a silently lowered depth would read as the complete closure |
edge_types | USES_COMPONENT, BINDS_VARIABLE, USES_STYLE, STYLE_BINDS_VAR, ALIASES_VARIABLE. Narrows which relationships are followed; it does not select the node |
direction | outbound = what this uses · inbound = what uses THIS, the impact question · both (default). It applies to every hop |
limit | Entities per page, 1–200, default 50. It bounds what is returned, not what is walked |
cursor | From 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
| Route | Scope | |
|---|---|---|
GET /v1/documentation/guide | read:docs | The order of the steps. Start here. |
GET /v1/documentation/block-types | read:docs | What shapes exist, and where each may live ?section=criteria|shapes serves the authoring guide one piece at a time. |
GET /v1/entities/{type}/{id}/documentation | read:docs | The blocks of one entity, plus a mentions branch: what they reference, resolved |
POST /v1/documentation:validate | read:docs | Validates without writing |
PUT /v1/entities/{type}/{id}/documentation | write:docs | ⚠️ Replaces ALL documentation of the entity |
POST /v1/entities/{type}/{id}/documentation | write:docs | Adds at the end. Retrying duplicates |
DELETE /v1/entities/{type}/{id}/documentation/{block_id} | write:docs | Hard delete by id. No trash, no undo |
POST /v1/entities/{type}/{id}/documentation:batchDelete | write:docs | Several 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
| Route | Scope | |
|---|---|---|
GET /v1/documentation/pages | read:docs | The pages of this project — and in paths, the only aggregated view of which folders exist |
GET /v1/documentation/pages/{page} | read:docs | One page whole: title, slug, folder, position and its blocks. Takes the uuid or the slug |
POST /v1/documentation/pages | write:docs | Creates an EMPTY page. Read the slug out of the response |
PATCH /v1/documentation/pages/{page_id} | write:docs | Rename, 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:rename | write:docs | Renames a folder across every page carrying it. Answers { updated } |
POST /v1/documentation/paths:clear | write:docs | Unfiles 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.
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:
:presignwithkind,entityType,entityId,contentTypeandsizeBytes→upload_urlandr2_key;- you
PUTthe bytes toupload_urlwith the sameContent-Type; :confirmwith ther2_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
| image | file | |
|---|---|---|
| accepted types | PNG, JPEG, GIF | PDF, DOCX, PPTX, XLSX, ODT, ODP, ODS |
| per-object cap | 3 MB — a limit of FORMAT, the same on every plan | a plan property; 10 MB today |
| team total | the plan’s storage quota, reserved before the URL is signed | same |
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.