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. 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_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 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
queryRequired. 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_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

Five engines, and the result says which matched

EngineOver whatFinds
semanticthe entity’s indexed documentation, by vector similaritymeaning — text input → Input Field
textthe same documentation, full-textthe words the documentation actually uses
namethe entity’s name, ILIKE (with a pg_trgm fuzzy floor below it)the name, and only the name — but over 100% of the catalogue
graphedges measured by the Figma sync, expanded outward from the other engines’ hitsentities structurally connected to a match
declaredrelationships a person asserted between entities — not measuredan 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
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.

GET /v1/tokens:export

The whole token system as DTCG documents, ready to feed a build. Returns three things:

filesA map of file path to DTCG document
resolverThe DTCG resolver document — how the modes combine
reportWhat the export could not carry
Parameter
include_hiddentrue 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:

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 — 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:

provenancewhat you get
derived (default)the measured graph — the impact question
declaredonly what people asserted
anyboth

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
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, 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
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. 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_depth0..16, default 0: direct uses only. 16 reaches everything.
typescomponent · token · style · pattern, repeatable or comma-separated: keeps only those kinds in the answer.
limit1..200, default 50.
cursornext_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

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}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}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.


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.

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 sizeBytes → upload_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.