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. A set’s variants are not rows: read them from GET /v1/components/{id}/spec, under spec.variants[]. The parent_id filter that promised them was retired, and sending it is a 400 |
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 its identity — figma_key, the Figma id that persists across files, used to
find the component in the library — and its derived signals chi_status, has_docs, has_spec.
has_drift is not in the default payload — it is an audit signal, requestable through
fields. A component deleted in Figma is still readable by id — sync_status tells you.
:id is a Grails id or a Figma reference, url-encoded: a node id (1234:5678), a node id with its
file (<file key>/<node id>), or a component or variant key; a variant’s answers its set. The same
goes for …/spec (where a variant’s reference serves that variant) and …/build-order. A reference
two files share is a 404 GRAILS_COMPONENT_NOT_FOUND with details.matched, each with its
file_key; one in a project whose sets hold variants with that kind of id unrecorded is a 404 with
details.reason: "not-indexed" and sets_without_ids: it may be one of those variants, and in its
library file the variant’s node id may resolve it.
name is the component’s own name ("Services"), never the Figma layer path above it, and path
is its Figma folder. path is null until the component re-syncs, which is not the same as sitting
at the top level — that is "". Names can repeat: path tells two apart, and id or figma_key
identify one. Every row that is a component carries the pair — this list, the detail,
entities:match, related and the build order. A reference to a component inside another
response — a graph end, a via step, a mention — carries only its name.
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, path, 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.
GET /v1/components/:id/pairing · POST /v1/components/:id/pairing
Which element, prop and prop value of a Figma component is which of its code component: the REST twin of
getComponentPairing and writeComponentPairing (see the MCP page for what a link is and how Grails makes them).
:id is the Figma component, the code component or the entity.
GET answers { canon_id, pairs, pair, choose_pair }. With more than one pair, pass design and code (a
pairs[] entry’s ids) to read one. The view parameters configuration, code_configuration, modes and booleans
are JSON objects in the query (?modes={"Spacing":"Compact"}), as pair.state.can_ask names them. Needs
read:library.
POST takes { design, code, operations, expect?, dry_run? } and runs the operations on that pair, in order and all
or nothing: { "op": "link", "part": "prop_value", "design": "Tone", "design_value": "Info", "code": "tone", "code_value": "info" }
or { "op": "unlink", "decision_id": "…" }. design must be a Figma component of :id’s entity. It answers
{ dry_run, results }, one per operation. Needs write:pairing.
GET /v1/components and GET /v1/components/:id answer each component’s pairing (per pair: linked, and open in
Figma and in code), and the list takes lives_in (design, code, both) and pairing (open, complete).
GET /v1/entities:match takes the same two, applied to the ranked results. A component’s spec answers pairing_map,
the links by key.
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.invalidPropCombinations is
ignored here: it is derived from the same fact this check reads, which configurations have an
element tree, so subtracting it would leave nothing to report.
Entities
GET /v1/entities:match
Scope: read:library
Find a documentable entity by term or concept. The eight types are the ones the documentation endpoints
accept, but for pattern: a pattern is documented the same way and found with
GET /v1/patterns.
It replaces GET /v1/entities:search, which served the name engine alone under a flat response
shape. Same filters, five engines, and a response that says which of them answered.
One hit per entity. The engines find Figma rows, and the same component in two files is two rows
of one entity: they are folded into one hit before the ranking, with the best rank each engine gave
either of them. entity_id is the canonical id; representation_id names the row the hit was read
from, only when it is not the entity’s own.
| Parameter | |
|---|---|
query | Required. A term, a name fragment, or a concept. The name engine matches a literal case-insensitive SUBSTRING (% and _ match themselves; no stemming, no synonyms — but a typo or a plural DOES reach a near miss through a pg_trgm fuzzy floor, ranked below every literal match and marked signals.name.matched: "fuzzy" with a similarity in 0..1). 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 |
Five 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 (with a pg_trgm fuzzy floor below it) | the name, and only the name — but over 100% of the catalogue |
graph | edges measured by the Figma sync, expanded outward from the other engines’ hits | entities structurally connected to a match |
declared | relationships a person asserted between entities — not measured | an opinion about connection: weigh it high for relevance, low for impact |
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. A component or
doc-page match also carries path, its folder — what tells apart two components with the same
name.
One of the engines is not a measurement
Every engine but declared measures something: a name matches, a meaning is close, an edge was
extracted from the Figma file. declared is the one where a PERSON asserted the connection —
“these two go together”, “this one replaces that one”.
It arrives as its own entry in matched_by, never folded into graph, and that separation is the
point: the same list would otherwise mean two different things depending on the row behind it, with
nothing in the response to tell them apart.
Weigh it high for relevance and low for impact. A person saying two components belong together is excellent evidence for “what should I look at?” and no evidence at all for “what breaks if I change this?” — an opinion is not a dependency. For that second question use the graph endpoint, which defaults to measured relationships only.
coverage.declared.ran: true with no results is a real answer, and today it is the usual one:
nobody has declared anything yet. It is not the same as the engine failing.
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.
GET /v1/tokens:export
The whole token system as DTCG documents, ready to feed a build. Returns three things:
files | A map of file path to DTCG document |
resolver | The DTCG resolver document — how the modes combine |
report | What the export could not carry |
| Parameter | |
|---|---|
include_hidden | true to include variables marked “hidden from publishing” in Figma. Default false |
Never exported: anything deleted in Figma, with no option to include it. Excluded by default:
variables hidden from publishing — that flag is the designer saying “internal, not for
consumers”, and an export is exactly what reaches consumers. 160 of 783 variables carry it in a
real design system, so this is not a rare corner; when they are excluded the omission is declared as a lost
finding, so leaving the flag off never costs you silently — and when you include them nothing is
omitted, so no such finding appears.
The response carries an ETag over the documents. Send it back as If-None-Match to get a
304 instead of the body — and that 304 is also the reliable way to confirm the bundle is the one
the MCP tool describeTokenExport described.
Size, so you can plan: ~139 KB of JSON for the largest design system we host.
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 — or, when the binding
also carries candidate_token_ids, that several do and none is in the style’s own Figma file, so
none was picked.
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
How many components drift, and against which minimum schema version. Counts only — it does not
return the components. To enumerate them, GET /v1/components?has_drift=true, which paginates
and takes fields.
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. An end is a reference, so it
carries no path.
edge_types narrows the result — it does not select the node.
Two kinds of edge live here, and provenance picks between them
A derived edge is what the sync measured off the Figma file: this component uses that one, this style binds that variable. A declared edge is what a person asserted about two entities — that they pair well, that one replaces another.
provenance defaults to derived, and that is the answer to “what breaks if I change this?”.
Somebody’s opinion is not an answer to that question, so it does not arrive unless you ask:
provenance | what you get |
|---|---|
derived (default) | the measured graph — the impact question |
declared | only what people asserted |
any | both |
Every edge carries provenance, in all three cases, so you never have to infer it from the verb. The
measured verbs are UPPER_CASE; the asserted ones are lower-case.
Naming an asserted verb in edge_types while provenance is derived is rejected, not answered
with an empty list: no edge could match, and an empty page would read as “this entity has no such
relationships” — a statement about your data rather than about two arguments that disagree.
⚠️ The related-entities walk (relatedTo) follows measured relationships only, and has no
provenance. It reports structural distance, so an asserted relationship is not a hop.
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.
The ends are entities, not Figma files
Each end’s id is the entity’s canonical id, the same one getEntities answers with, and the path may
name any of the entity’s representations. The same component in two Figma files is one node: when both
files record the same relationship to another entity, the answer has one edge for it.
That edge also carries edge_count and origins. Each origin is one stored edge,
{ edge_id, source_id, target_id, metadata }, with the ids of the representations at each end. Each
origin keeps its own metadata, so the edge’s top-level metadata is null: summing two files’ usage
counts would invent a number neither file recorded. The two fields also appear when an end is not its
entity’s own row. When the design system holds one source per entity, which is almost always, neither
field appears and the edge looks as it always did.
next_cursor is opaque: pass it back as it came.
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, PATTERN_USES_COMPONENT, PATTERN_CONTAINS_PATTERN: the walk follows measured relationships only. Narrows which relationships are followed; it does not select the node. A component reaches the patterns that use it, and a pattern what it uses and the patterns that contain it; each pattern carries its status. A code sync’s edges (CODE_*) and the asserted verbs are not followed, and naming one is a 400: read them on /graph, where provenance picks measured or asserted |
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. A result whose type lives in a
folder also carries path; a via step names the node it passed through and nothing more.
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.
GET /v1/entities/{entity_type}/{entity_id}/blast-radius
What a change to one component, token, style or pattern would reach (since v1.317.0): everything that uses it,
directly or not, in Figma and in code, and the patterns that use it. The MCP tool is getBlastRadius; the answer is
the same.
| Query | |
|---|---|
max_depth | 0..16, default 0: direct uses only. 16 reaches everything. |
types | component · token · style · pattern, repeatable or comma-separated: keeps only those kinds in the answer. |
limit | 1..200, default 50. |
cursor | next_cursor from the previous page, with every other parameter identical. |
One row per entity, by its canonical id, with depth, via, through and sides; a variant counts for its set and a
deleted entity is not an answer. A pattern has sides: [] and its status (draft or published): drafts are
answered too. counts covers every page. 504 GRAILS_TIMEOUT means the walk did not finish within
the time budget: ask a lower max_depth. Unlike …/related (every relation, both ways, at most 3 hops), it follows
only the relations of use, inbound.
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} | 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} | 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.
Patterns
A pattern describes how components are composed for one use (an empty state, a PIN step, an account home) and
the rules for doing it: screens of sections, each saying what it accepts. It lives in a project and is a draft or
published.
GET /v1/patterns
The project’s patterns, the most recently changed first, without their specs; with no filter, every one:
{ data: [{ id, canonical_id, name, slug, status, kind, steps, shape, updated_at, updated_by, updated_via }], pagination }. q matches a piece of the name; status, kind, component, domain (exact, case-sensitive) and platform
narrow it. component takes up to 20 comma-separated ids: the patterns that use any of them, or all of them with
uses_mode=all. created_by and updated_by take people’s ids (as updated_by.id answers them), comma-separated.
order is updated_desc (the default), name_asc or created_desc; a cursor belongs to its order, and sent
with another it is a 400.
include (comma-separated: intent, created, review, uses) adds fields. Needs read:patterns.
GET /v1/patterns/{pattern}
One pattern by id or slug: { pattern, review }. pattern.spec is the typed spec (specVersion: 2: intent,
screens of sections, rules). review is computed when you read, against your design system as it is now: ok, or
needs-review when a component the pattern names was deleted, or no longer has a property or value one of its
constraints asks for, each issue with its path into the spec. Needs read:patterns.
POST /v1/patterns · PUT /v1/patterns/{pattern_id}
Create a pattern, or replace one’s name, status and whole spec. Needs write:patterns, which does not include
reading. A new pattern is a draft unless you send status: "published"; on a replace, a slug or status you
leave out keeps the one it has. The spec’s shape, with an example, is in writePattern.
Every problem in a spec comes back at once, each with its path (screens[0].sections[1].accepts[0].canonId), as
422 GRAILS_VALIDATION_FAILED. A spec with specVersion: 1 is a 422 too, with details.field: "spec.specVersion",
and its details.errors lists every other problem the spec has.
A component that is not in this project is a 422 that says where the spec names it.
An explicit slug already in use is 409 GRAILS_SLUG_TAKEN. The author is your key’s owner, and the key is
recorded on the pattern.
On a PUT, expected_updated_at guards the replace: send the updated_at you read (from GET or from your
previous write, which answers it). If the pattern was saved since, nothing is written and the answer is
409 GRAILS_PATTERN_CHANGED, with details.current_updated_at (ISO 8601, to the millisecond: send it back to
guard your next replace). Microseconds, as the API answers them, and
milliseconds, as a JavaScript Date gives them, both match. Without it, a replace overwrites whatever is there.
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.