The review API
What a connection carries, what comes back, what a key can and cannot reach, and the limits. Written to be pasted into an integration ticket or a supplier assessment.
Authentication, and what a key cannot do
A key travels in its own header rather than in Authorization, which carries the platform credential and is validated as a JWT before a request reaches us.
POST /functions/v1/review
x-api-key: ink_…
Authorization: Bearer <your project's publishable key>
apikey: <the same publishable key>
Content-Type: application/jsonAn account owner issues a key at Account → API keys, scoped either to every brand in the account or to one of them. The key is generated in your browser and only its SHA-256 is stored, so it is shown once and cannot be read back — by you or by us. Lose it and the way forward is to revoke it and issue another.
The same endpoint also answers a signed-in caller presenting a normal session, which is how the studio reaches it. That path reads rules through row-level security with the caller’s own identity, so it can see exactly what that person can see and no more.
- Two scopesreview · dispose
- review checks content. dispose additionally records an override, and is asked for when the key is issued rather than added to one later. A key can never read your work, change a rule, or export — the widest key is narrower than any human role.
- What it reaches
- One brand, or every brand in the account, chosen when the key is issued and not changeable afterwards. Revoke it and issue another; moving a credential’s tenant boundary under the integration already holding it should not be an update.
- What it checksruleClasses
- A separate question from what it may do. A key checks text, fixed at issue and not widenable afterwards — so what a key reaches on the day it is revoked is what it reached on the day it was minted.
- Where the secret lives
- Nowhere. Only a SHA-256 is stored, the key is shown once, and nobody — including us — can read it back.
- Revocation
- Immediate and permanent. A revoked key stays listed so the overrides it recorded still name something, and cannot be reinstated.
- Deleting a brand
- Revokes the keys scoped to it rather than deleting them, so a dead key still names the actor on every override it recorded.
What you send
Blocks of text, each with a role, a location a person can read in an audit record, and your own address for it.
{
"workspaceId": "8f2c…",
"blocks": [{
"role": "artwork",
"location": "Frame 3 · title",
"text": "A cure for chronic fatigue.",
"ref": { "node": "12:445" }
}],
"review": false,
"zeroRetention": false,
"ruleClasses": ["text"]
}- workspaceIdrequired
- The brand whose rules the content is checked against. Not a campaign: an integration checks copy against the standing rules of the brand it belongs to, and a campaign’s embargo is a fact about a launch a plugin cannot know it is inside.
- blocks[].rolerequired
- One of six, below. Rules scope by it. A block with an unrecognised role is refused rather than coerced — a block that silently escaped scoping would be checked by nothing.
- blocks[].locationrequired
- What a person reads in an audit record. Overrides are keyed to it, so changing your location strings later detaches every override already recorded against them. It is a wire format, not copy to be improved.
- blocks[].textrequired
- The content.
- blocks[].refoptional
- Your address for this text — a node id, a paragraph index, a cell reference. Never interpreted, and echoed back on every finding so you can paint the result onto the thing it is about.
- blocks[].numericoptional
- This block exists to hold a figure. A bare integer in a stat field is a figure; the same integer in a headline is usually a list length, and only the surface that produced the block knows which.
projectId was this field’s name while a brand was called a project. It is refused rather than accepted as an alias, because it now names a campaign — a real id at a different level.
The six roles
Every block declares one of six roles, and the list is closed. Not a slide, a paragraph or a cell among them — so one rule reaches a slide headline, a document heading and a frame title alike, and the engine never learns what any of those is.
- artworkOn the artwork
- Drawn in the design itself: a slide, a frame title, a poster.
- bodyDocument copy
- Running copy in a document.
- captionCaption
- Published beside the artwork rather than inside it.
- metadataTags and hashtags
- Machine-facing and still published: hashtags, tags, keywords.
- alt_textImage descriptions
- An image description.
- notesNotes, unpublished
- Written and not published: speaker notes, internal comments.
What comes back
{
"checkedAt": "2026-07-29T21:04:11.882Z",
"checkedBy": "Figma plugin",
"blocks": 12,
"rules": 14,
"blocked": true,
"findings": [{
"ruleId": "pharma-cure",
"kind": "banned_phrase",
"severity": "block",
"statement": "Never claim a cure.",
"location": "Frame 3 · title",
"role": "artwork",
"excerpt": "cure",
"replacement": "helps manage",
"span": { "start": 2, "end": 6 },
"ref": { "node": "12:445" },
"tier": "verified"
}],
"verifiedRuleIds": ["pharma-cure"],
"reviewedRuleIds": [],
"steeredRuleIds": ["substantiation"],
"partiallyCheckedRuleIds": [],
"outOfScopeRuleIds": ["house-palette"],
"scope": {
"inForce": ["text"],
"declared": ["text", "visual"],
"outOfScope": [{ "class": "visual", "reason": "surface" }]
},
"review": { "requested": false, "ran": false, "model": null, "error": null }
}- findingsarray
- Every violation, with the rule that caught it, the severity, the tier, the offending excerpt, a half-open character range into the block, and your own ref echoed back.
- blockedboolean
- At least one finding is blocking rather than advisory.
- rulesnumber
- How many rules were in force for this call, at every level. The denominator the four lists below add up to.
- verifiedRuleIdsarray
- Code checked these and a clean result is a fact.
- reviewedRuleIdsarray
- A model read these. Empty unless the call asked for a review and it ran.
- steeredRuleIdsarray
- In force, queued for judgement, and nothing looked at them. This is the list an integration must show.
- partiallyCheckedRuleIdsarray
- Swept for something specific and found nothing — which does not clear the rule. A subset of steeredRuleIds, not a fifth list, so adding the two double-counts.
- outOfScopeRuleIdsarray
- In force and deliberately not examined: outside the classes this call checked, or scoped to a content role the request did not carry. In no other list.
- scopeobject
- inForce, declared, and outOfScope with a reason per class. What this call checked and what it did not.
- reviewobject
- requested, ran, model, error. Whether a model pass was asked for and whether it happened.
- checkedAtstring
- When the run happened, ISO 8601.
- checkedBystring
- The name of the key the account attributes this check to. Never the key itself.
- blocksnumber
- How many blocks were checked.
- unjudgedReasonsobject
- Why a rule went unjudged, in words, keyed by rule id. Present for rules in the steered, partial and out-of-scope lists; the reason is what a caller acts on.
An empty findings array is not a pass. It means nothing was found by the rules that actually ran. verifiedRuleIds, reviewedRuleIds, steeredRuleIds and outOfScopeRuleIds are disjoint and together account for every rule in force. An integration that reports “passed” without showing what was and was not checked is making a claim this API does not make — see what a result establishes.
A finding may also carry source: which of the account’s own documents the rule is written in, and where inside it. It is absent on most findings today, and absence means not recorded rather than no source. Where source.edited is true the rule was reworded after it was drafted, so the citation is a derivation and never a quotation.
What this call checked, and what it did not
A rule is about words or about how something looks, and a surface can usually see one of those. A Word add-in and a Figma plugin would both call this endpoint, and only one of them would know what colour anything is. Neither exists yet — the endpoint is the thing somebody builds them against, and it cannot tell which of them is calling.
So the scope is declared, never inferred. Working out what a caller can probably see and quietly omitting the rest produces a response that did not mention brand rules; this produces one that says brand rules were out of scope. Only the second is defensible a year later, when nobody remembers what the checker could do in July.
- 1. The key
- States its classes. New keys are ["text"]. Read off the key on every request and never accepted from the caller — a scope a request could assert is not a scope.
- 2. The request
- May send ruleClasses to narrow further, for a caller that knows it is holding less than the key allows.
- 3. This endpoint
- Can only check text. A block is a role, a location and a string; there is no colour, typeface or brand mark in the wire format, so no visual rule can be evaluated here whatever a key declares.
The three are intersected, never unioned. Asking for a class your key does not carry does not widen anything and is not an error — it comes back in scope.outOfScope, so a pipeline does not start failing the day an owner narrows the key it uses. Asking for nothing that survives the intersection is an error: a call that checks nothing would return a clean-looking response over content against which no rule ran.
- surfacereason
- This call cannot see that class at all. Permanent, and nobody’s mistake. Use an integration that can.
- configuredreason
- Somebody switched it off, on the key or on this request. The account owner can change it, or your own request can.
Absence of a finding for an out-of-scope class is not a pass. If visual is in scope.outOfScope, the response says nothing whatsoever about whether the content uses an approved colour — not that it does, and not that no such rule exists. Nothing ran.
The model pass is opt-in
By default the endpoint runs the deterministic checker and no model, so rules that need judgement come back in steeredRuleIds: in force, and inspected by nothing. Send "review": true to run the model over exactly those rules; what it inspects moves to reviewedRuleIds and its findings arrive tagged reviewed, never verified.
Opt-in because the two callers want opposite things. A build pipeline checking every commit wants the deterministic answer in milliseconds and would not thank you for a model call it did not ask for; an add-in with a person waiting wants what the studio gives.
- requested
- Whether you asked.
- ran
- Whether it actually ran.
- model
- Which model, when it ran.
- error
- Why not, when it did not.
requested and ran are separate on purpose. “You did not ask” and “you asked and it failed” are different facts about the same clean-looking result, and a surface that collapses them will eventually report an errored review as “reviewed, no findings”. When a review fails, its rules stay in steeredRuleIds where they belong.
"zeroRetention": true requires a provider that retains nothing. If no such provider can serve the model the call fails rather than quietly falling back.
Limits
- 500
- blocks in one request
- 20,000
- characters in any one block
- 200,000
- characters across the request
- 120
- calls a minute per key
Over any of them the request is refused rather than truncated. A checker that quietly skipped the last of your copy and reported no findings would be worse than one that said no.
The rate limit is a ceiling on damage rather than a commercial one: a leaked key should cost an attacker time, and a plugin looping on an error should not be able to empty an account’s allowance before anyone notices.
Status codes
- 400
- Malformed request, unknown role, unknown rule class, a scope that would check nothing, or over a size limit. The message says which.
- 401
- The key is missing, invalid or revoked.
- 403
- The key is not scoped for this, the brand is not reachable with it, or it is not the brand the key was issued for.
- 429
- Over 120 calls in a minute on this key.
403 is returned both for a brand in another account and for one that does not exist. The two are indistinguishable on purpose — otherwise this would be a way to probe for brand ids.
A brand-scoped key asking for a different brand in its own account gets a 403 with a distinct message. Saying so is safe, because the holder already has a valid credential for that account, and it is nearly always what has actually gone wrong.
One engine, not one per surface
The endpoint imports the checker the editor runs. The same function, not a port of it and not a reimplementation kept in step by discipline.
Two implementations of “is this allowed” eventually disagree, and the day they do you have content one surface cleared and another flagged. Nothing recovers a compliance tool’s credibility after that conversation.
So the API cannot be more permissive than the studio. Rules set on the account hold over it, house rules included, and there is no mode that checks content against rules supplied in the request — a verdict is only meaningful against a real rule set a real account owns.
Recording a human’s decision to override a finding is the other endpoint, and it is the reason an integration is worth building rather than being a lint pass. See overrides and set-asides.
