Public API (v2)
Purpose
The v2 public API is the surface Assessor.io exposes to systems outside itself: a CRM that wants to push its contacts in as campaign participants, a data warehouse that wants completed submissions out, an LLM agent that wants to answer questions about a campaign. It is deliberately narrow — eleven operations across five resources — and deliberately stable.
Everything under /api/v1 is the application talking to itself. Do not build
an integration on it: it is unversioned in practice and changes with the UI.
What you need before you start
You need three things, and a platform operator issues all three from one screen:
- A service token. Tokens are minted at
/console/api-credentialsin the app. The plaintext is shown ONCE, on creation, and only a hash is stored — losing it means minting a replacement, not recovering it. (The olderAGENT_SERVICE_TOKENS_JSONenvironment path is deprecated and being retired; it is honoured only so existing holders keep working.) - The account ids the token is scoped to. A token that reaches no account is refused at issuance rather than 403ing on every call.
- The scopes your integration needs (see the table below). Scopes are not
inferred from each other — a token with
campaigns:readcannot read participants — and the two that reach answer content are marked Sensitive on the issuing screen.
A token may also carry a per-minute request budget. The default is 1000 requests per minute, counted per token.
Authentication
Send the token as a bearer credential on every request:
Authorization: Bearer <service-token>
A token is shaped asr_<random>.<signature> and is self-verifying, so a bearer
we did not mint is refused without a database read.
The token prefix changed on 2026-09-17 — your token still works
Tokens minted before that date start aio_. They keep working, unchanged,
until 2026-12-31 (owner ruling D-176): both prefixes are accepted, nothing
stored was rewritten, and you do not need to re-issue a credential. Tokens
minted from now on start asr_.
What this touches, if you have automated anything around it:
- A secret scanner or log filter keyed on
aio_should learnasr_as well. - A client that validates the prefix before sending should accept both, or stop validating — the server is the only thing that can decide whether a token is real.
- After 2026-12-31, a token still carrying
aio_is refused with401. Mint a replacement at/console/api-credentialsbefore then; the console shows each credential's prefix in its hint, so you can see which ones are affected.
The MCP tools are renamed on the same window and the same date — see MCP Server.
Each request runs the same four-step gate, in order:
| Step | Failure |
|---|---|
| Resolve the principal from the token | 401 Unauthorized |
Check the token is scoped to the accountId in the path | 403 Forbidden |
| Check the token holds the operation's required scope | 403 Forbidden |
| Check the token is inside its per-minute budget | 429 + Retry-After seconds |
Operations
Base path: /api/v2/public/accounts/{accountId}
| Operation | Method and path | Scope |
|---|---|---|
| List campaigns | GET /campaigns | campaigns:read |
| Read a campaign | GET /campaigns/{campaignId} | campaigns:read |
| Read the assessment inventory (the codebook) | GET /campaigns/{campaignId}/assessments | campaigns:read |
| List participants | GET /campaigns/{campaignId}/participants | participants:read |
| Upsert a participant | POST /campaigns/{campaignId}/participants | participants:write |
| List submissions | GET /campaigns/{campaignId}/submissions | submissions:read (+ submissions:read:answers for answer content) |
| Export results | POST /campaigns/{campaignId}/exports | results:export |
| List CRM contacts | GET /contacts | contacts:read |
| List webhook subscriptions | GET /webhooks | webhooks:manage |
| Register a webhook | POST /webhooks | webhooks:manage |
| Activate or deactivate a webhook | PATCH /webhooks/{subscriptionId} | webhooks:manage |
| Unsubscribe a webhook | DELETE /webhooks/{subscriptionId} | webhooks:manage |
| Search Action Statements | GET /retrieval?types=statement | statements:read |
| Search library items / prior template questions | GET /retrieval?types=item,template_question | statements:read |
| List templates | GET /templates | templates:read |
| Read a template | GET /templates/{templateId} | templates:read |
| Validate a template body (no write) | POST /templates/validate | templates:read |
| List answer scales | GET /scales | templates:read |
| List labels | GET /labels | templates:read |
| Start a Blueprint from pasted text | POST /blueprints | blueprints:write |
| Read a Blueprint | GET /blueprints/{blueprintId} | templates:read |
| Check a Blueprint's readiness (no write) | POST /blueprints/{blueprintId}/validate | templates:read |
| Run the BIMei AI Assistant over a Blueprint | POST /blueprints/{blueprintId}/aia | blueprints:write |
| Compile a Blueprint into a Draft template | POST /blueprints/{blueprintId}/compile | blueprints:write + templates:write |
The machine-readable contracts live in the repository under openapi/v2/specs/
— one file per resource group, with the full request and response schemas.
Template authoring (C3)
The last nine operations above let an external system drive the same template-authoring pipeline the in-app BIMei AI Assistant uses: search Action Statements and reusable library items (ONT-first), read the account's own templates/scales/labels, start a Blueprint from pasted brief text, run the assistant over it step by step, check it, and compile it.
POST /blueprints/{blueprintId}/compile is the only operation in this
group that creates a template, and it always creates it with
lifecycle.status: Draft (owner ruling D-220 R4). Nothing in this API can
set a template Active, distribute it, or attach it to a campaign — that stays
a human action taken in the app. Compiling is gated on two scopes at
once, blueprints:write and templates:write: a token provisioned only to
run the assistant is refused with a named 403 rather than being allowed to
leave a Draft template behind it.
POST /blueprints/{blueprintId}/aia is a model route — it calls an LLM
and is refused with 503 while the platform's model kill switch is on,
checked before authentication. It costs money per call; provision
blueprints:write deliberately.
An MCP client reaches this same pipeline through the tools documented in
MCP Server — asr_search_statements through
asr_compile_blueprint.
Response shape
Every JSON response uses the same envelope.
Success:
{ "success": true, "data": ..., "meta": { } }
Failure:
{ "success": false, "error": "Forbidden: missing required scope 'participants:read'" }
List operations add pagination meta:
{
"success": true,
"data": [],
"meta": { "page": 1, "limit": 50, "total": 0, "totalPages": 1 }
}
Pass ?page= and ?limit=. limit is clamped to 1–200 and defaults to 50;
page is 1-based. Unparseable values fall back to the default rather than
erroring. The webhook list is the one exception — it is not paginated.
All timestamps are epoch milliseconds.
What the API will not tell you
Responses are whitelist projections, not database rows. If a property is not in the contract, the API does not return it — this is enforced in code, not by convention. The deliberate exclusions:
| Resource | Excluded, and why |
|---|---|
| Campaign | The access passcode value (only the enablePasscode boolean is exposed), shareWithAllAccountUsers, branding, and the raw settings object |
| Participant | The linked Assessor.io userId, avatar URL, and assigned-auditor records |
| Submission | Answer content, unless the token holds submissions:read:answers |
| Export | Auditor review data — verdicts, corrected-value markers, reviewer keys and notes — unless the token holds submissions:read:answers |
| Contact | The free-form metadata bag (internal notes, captured form messages, sync correlation ids), the owning user, the source reference, and the archive timestamp |
| Webhook | The signing secret, always — including in the response to the call that created it |
Optional properties are omitted rather than returned as null.
Answer content is gated twice, differently
This trips people up. There are two separate doors to answers, and they do not share a key:
GET /submissionsreturns answers only if the token holdssubmissions:read:answersin addition tosubmissions:read. A metadata-only token can watch completion rates without ever seeing a response.meta.answersIncludedtells you which you got, and when it isfalsemeta.reasonsays why — withholding answers is an ordinary200with the same page size and noanswerskey on any row, so without that line an empty-looking listing is indistinguishable from an empty campaign.- Before 2026-09-10 this scope could not be minted at all. The issuing
route refused it as an unknown scope, so a console-issued token read
answersIncluded: falsefor ever whatever you asked for. If you were told to request it and were handed a token that never returned answers, that is why — ask for a replacement credential rather than working around it.
- Before 2026-09-10 this scope could not be minted at all. The issuing
route refused it as an unknown scope, so a console-issued token read
POST /exportsalways contains real answers.results:exportis the only gate for those. Provision it deliberately.
There is a third door, and it uses the second key. An export always applies the
auditor's corrections — withholding them would hand you numbers that disagree
with the scorecard your client is reading — but who corrected what, when, in
what words, and the per-answer verdict columns are disclosed only to a token
that also holds submissions:read:answers. export.reviewsIncluded tells you
which file you got, so false is never confused with "nobody has reviewed
anything". Asking explicitly with filters.includeReviews: true and no scope is
refused with 403 and code: "REVIEW_SCOPE_REQUIRED" rather than downgraded.
That door covers the json arm's raw answers object too, not only the
columns. With submissions:read:answers it is the respondent's bag as stored,
before the merge — the lossless record, and no more than the
ans:<question>::original columns beside it already show you. Without the
scope it is the merged bag, identical to the ans: cells, so the
pre-correction values cannot be recovered by diffing the two. The codebook
entry for answers states which of the two your file holds.
Reading submissions
GET /campaigns/{campaignId}/submissions returns completed and reopened
submissions. In-progress drafts — autosaves from somebody who is still
answering — are not submissions, and they are excluded from both data and
meta.total.
That is the same population POST /exports produces, deliberately: before
v2 1.1.0 this listing counted every row, so the two doors of one API
answered "how many submissions are there" with different numbers, and the
larger of the two was the one a warehouse polls.
meta.draftsExcludedsays how many rows the default left out of this listing, counted after your other filters.0there means "nothing was dropped", which is not the same as "there are no drafts" — it is also what you get when you named a population yourself.?status=is unchanged and wins over the default. It is an exact match on the stored lifecycle status,?status=in_progressincluded, so it remains the way to ask for the drafts alone.?includeDrafts=1restores the whole pre-1.1.0 population in one call, the wayfilters.includeDraftsdoes on the export. Only1andtruecount;?includeDrafts=falsekeeps the default rather than being read as a truthy string.
If you were reading the unfiltered meta.total as a completion count, it
was the wrong number and the new default is what you wanted. If you were reading
it as "rows in this campaign", pass includeDrafts=1.
unitKey: what one submission speaks for
Not every assessment is answered by a person. An assessment declares its unit
of participation — each participant for themselves, an organisation once, or
once per department (or project, or whatever label group the campaign keys it
by). The assessment inventory carries that declaration as unit; a submission
carries unitKey, which names which one:
unitKey | Means |
|---|---|
participant:<participantId> | One person answered for themselves |
organisation:<organisationId> | This organisation's single answer |
group:<labelGroupId>:<labelKey> | This department's (or project's) single answer |
Exactly one completed submission exists per assessment per unit instance, and
the database enforces it. So on an organisation-unit assessment, counting
distinct unitKey values counts organisations — no folding by employer name
or email domain needed, and no risk of counting one organisation twice because
two of its people were on the roster.
The field is absent on a submission that names no instance: a walk-up who
arrived through an open link, a row filed before this shipped, and a respondent
the unit could not place (no organisation on their roster row, no label from the
assessment's group, two labels from it, or an assessment whose label group has
since been deleted). Such a submission is governed by the older
one-per-participant rule instead, so treat a missing unitKey as "this row
speaks for its respondent", never as an error.
The export carries the same value in a unitKey column beside participantId,
described in export.codebook like every other fixed column.
Pushing participants in
POST /campaigns/{campaignId}/participants upserts on email, lower-cased. Send
the same contact twice and you update the participant rather than duplicating
them.
201when a participant was created,200when one was updated.meta.createdsays which happened.metadatais a free-form object, capped at 50 keys, round-tripped verbatim — use it for your own correlation ids. It is merged into whatever the participant already carries, key by key, so a push that names one key does not discard the rest of the bag.- Two keys in that bag are the system's and cannot be written, overwritten
or removed through this endpoint (v2 1.1.0):
takerLinkandsynthetic.takerLink.epochis the generation a signed taker link is checked against — overwrite it and every link somebody regenerated becomes valid again — andsynthetic.runIdis how a manufactured test cohort is identified and purged. A body naming either is still a200/201; the stored values simply stay as they are, andmeta.preservedKeyslists what was refused. It is not a400, because re-sending the whole bag you read a minute ago is the ordinary case for a sync. - A key sent as
nullis deleted (v2 1.2.0). Postingmetadata: {"crm_id": null}removescrm_idfrom the bag: read the participant back and the key is absent, not present with a null value. Before 1.2.0 the null was stored. Onlynulldeletes —false,0and""are ordinary values — and deleting a key that is not there is a no-op, including on a create.- This is the same meaning the v1
PATCHhas always had for the same body, which is the point of the change: one bag, one rule, whichever door you use. - If your sync posts its whole record with unset fields as
null, those keys are now being cleared rather than filled with nulls. Send only the keys you mean. metadata.segmentsis a key like any other, so a null clears the segment bag — unless the same request also names an axis, in which case the value wins.- The two system-owned keys above are the exception at this end too: a
nullfortakerLinkorsyntheticremoves nothing and is reported inmeta.preservedKeys. A removal is a write.
- This is the same meaning the v1
- What you read back in
data.metadatais the bag as the row holds it after the write, not the fragment you sent. - Every call is written to the account audit log as
via: v2_public_api.
Exports
POST /campaigns/{campaignId}/exports answers two ways.
The envelope (default). The file arrives inline in the JSON body. There is no signed URL and no external storage, so a large campaign produces a large response — budget for it.
xlsxarrives asexport.workbookBase64withexport.sheetNames.csvandjsonarrive asexport.content;export.contentTypeis on every arm.export.rowCountsandexport.populationsay which rows the file holds,export.codebookdocuments every generated column,export.shapeReportis the per-question census of answer shapes, andexport.notescarries the auditor notes (see below).
The file (?download=1). The same bytes, streamed, with
Content-Disposition and no Content-Length on the chunked csv/json arms.
Use it when you are writing the response to disk: it skips the base64 inflation
and the JSON copy of it on both ends. You lose the envelope's counts and
codebook, so ask for the envelope when you need those.
Format defaults to xlsx. Optional filters: assessmentId,
participantStatus, submittedFrom, submittedTo (both epoch milliseconds,
inclusive), includeDrafts, includeReviews.
The json arm is COMPACT (D-128). The scorecard-bundle and competency JSON
exports were pretty-printed; they are now written by the same streaming writer
as every other arm, which cannot also indent. The bytes changed — whitespace and
newlines only. Nothing about the structure, the key names, the ordering or the
values changed, so JSON.parse sees exactly what it saw before.
It matters in one place: if you pinned a checksum or a byte length of a downloaded file, or you diff yesterday's export against today's as text, that comparison now reports a difference that is not one. Compare parsed objects, or re-baseline the checksum. If you were reading the file with a JSON parser — the case this arm exists for — there is nothing to do.
What the columns hold
- The
ans:columns hold the auditor-merged value — the same substitution the scoring engine makes — so an export and a scorecard cannot state different numbers for one answer. ans:<question>::originalappears only where a correction actually changed the value, and holds what the respondent wrote.rev:<question>::status,::modifiedApplied,::locked,::reviewedAtIso,::reviewerKey,::reviewLeveland::reviewLevelLabeldescribe the review. A question nobody reviewed gets none of them, so an unreviewed campaign is no wider than before.::reviewLevelis the auditor's account-configurable review-level label id (D-174 — not a computed value);::reviewLevelLabelis that label's current display text, resolved at export time.- Every row carries
reviewCount,reviewVerifiedCount,reviewModifiedCount,reviewLockedCount,reviewNotesExportedCount,reviewNotesWithheldCountandreviewLastReviewedAtIso. - Every row carries
syntheticRunId,syntheticGeneratorandsyntheticSeed— blank for a real submission — so a downloaded file can be reconciled against a purge by run. export.notes(aNotessheet inxlsx) carries the auditor's own words. A review the auditor unticked include in export on contributes nothing here;reviewNotesWithheldCountis how the file declares what it is not carrying.
Reviewers appear as identity-folded keys (person:… / user:…). A reviewer's
email address is never exported.
The codebook: joining answers to the questions that produced them
q17: "3" is not data until you know what q17 asked and what 3 means on its
ladder. GET /campaigns/{campaignId}/assessments is the instrument behind the
answers — the question inventory projected from the SurveyJS body each
assessment was answered against (the pinned template version, not whatever
the template row holds today). It carries no respondent data, so it is gated on
campaigns:read: if your token can read the campaign, it can read the
questionnaire that campaign administers.
Per question you get the answer key, the type, the page the respondent met it
on, the title and every part label per locale, the values a stored answer
can take (matrix rows and columns, multipletext items, choices), the BIMmaas
coding frame (domain, theme, tier, thread, scale, role, qualifier,
weight), and visibleIf — the routing condition, verbatim.
How to join it
Each question carries exportColumns: the exact ans: headers the export
writes, in the same fields export.codebook uses. So the join is string
equality on the column name.
inventory = get(f"{base}/campaigns/{cid}/assessments")["data"]
by_column = {
col["column"]: (q, col)
for a in inventory["assessments"] for q in a["questions"] for col in q["exportColumns"]
}
question, column = by_column["ans:maturity::policy"]
question["title"]["byLocale"]["fr"] # the FR question text
column["partLabel"] # "Policy" — which matrix row this column is
next(c["label"]["text"] for c in question["columns"] if c["value"] == "3") # "Defined"
Three things worth knowing before you rely on it:
- A matrix is one column per row.
ans:<question>::<row>for amatrixormultipletext, andans:<question>::<row>::<column>for amatrixdropdown— never one JSON blob per question. Thejsonarm of an export additionally carries the raw object underanswers, which is the lossless record; the columns are the analysable form of the same thing. - Two columns an inventory cannot predict. The export plans its columns from
a census of the values actually stored, so a part nobody declared but somebody
answered, and the
::rawescape hatch for a value whose shape does not fit the plan, appear in a file and cannot appear in a questionnaire-only projection.export.shapeReportis where those are visible, per question, with the census that produced them. - A question name two assessments share belongs to the first. One file column
exists for it, so exactly one assessment claims it and the other's copy carries
an empty
exportColumns.
Match templateFingerprint between the inventory and the export rows to prove
the two describe the same questionnaire. languages on the response names the
locales the account has enabled, which is the set byLocale can carry.
Webhooks
Register a URL and a list of event types, and Assessor.io will POST to it when those events fire.
What arrives at your endpoint:
POST <your url>
Content-Type: application/json
X-Assessor-Event: campaign.results.published
X-Assessor-Delivery: dlv_01J8Z…
X-Assessor-Timestamp: 1757462400000
X-Assessor-Signature: sha256=<hex>
X-Assessor-Signature-V2: t=1757462400000,sha256=<hex>
{
"id": "dlv_01J8Z…",
"eventId": "evt_01J8Z…",
"event": "campaign.results.published",
"accountId": "acc_…",
"timestamp": 1757462400000,
"payload": { }
}
Deduplicating: id and eventId
id is the dedupe key. It is the delivery, so all three attempts of one
delivery carry the same id. Record it and drop a repeat — that is the whole
contract, and it is the only correct one: nothing else on the wire distinguishes
a retry from a genuine second event.
eventId is the thing that happened. One event fans out to every matching
subscription, so two POSTs sharing an eventId with different ids are two
subscriptions, not a retry. It is null on deliveries enqueued before this
existed.
timestamp is this attempt's clock in epoch milliseconds, so a retry is a
new timestamp against a stable id. Refuse a delivery older than your tolerance
window; five minutes is the recommended value, wide enough for the retry
schedule below and for ordinary clock skew. Assessor.io does not enforce the
window — your receiver does, and it is stated here so two receivers do not pick
two different numbers.
Verifying the signature
Two signature headers ship on every signed delivery, computed from the same secret. Without a secret, deliveries arrive unsigned and neither is present.
| Header | Covers | Use it when |
|---|---|---|
X-Assessor-Signature | HMAC-SHA256(secret, raw body) | You already implemented it. Unchanged, still sent, not going away without notice |
X-Assessor-Signature-V2 | t=<ms>,sha256= + HMAC-SHA256(secret, "<timestamp>.<raw body>") | New integrations. Prefer it |
Verify against the raw bytes of the body, not against a re-serialised object — re-serialising changes key order and whitespace and neither signature will match.
Why there are two. The timestamp is inside the body, so the v1 signature
does authenticate it — but only the body's copy. The X-Assessor-Timestamp
HEADER is outside v1's coverage, so a receiver that ages-out on the header alone
under v1 is trusting an unsigned number: read the timestamp from the body
under v1, or verify v2, which binds the header to the body. Changing what the v1
header covered would have broken every consumer silently — the POST still
arrives, the header is still well-formed, and it simply never matches — so the
new scheme is a new header instead.
Delivery guarantees. Any 2xx counts as accepted. Anything else is retried
up to three attempts with a 1-minute, 2-minute, 4-minute backoff, after which
the subscription's deliveryFailureCount increments. Deliveries are queued and
drained by a scheduled processor, so treat them as near-real-time, not
synchronous. Make your handler idempotent — and now you can, on id.
Event types. This is the complete catalogue — the closed set of names that can
reach a webhook. It is exported as WEBHOOK_EVENT_TYPES, the account webhook
screen renders it as a picker, and POST …/webhooks refuses an events
array naming anything else with a 400 that lists the offending names. A typo
used to be accepted and then silently never fire.
| Group | Events |
|---|---|
| Participants | participant.joined, participant.completed_all |
| Submissions | assessment.submission.created |
| Campaigns | campaign.created, campaign.updated, campaign.status.changed, campaign.registration.toggled, campaign.cloned, campaign.deleted, campaign.assessment_flow.updated, campaign.assessment_flow.validation_failed, campaign.assessment_flow.publish_failed, campaign.assessment_flow.published, campaign.transition.executed, campaign.results.published, campaign.results.unpublished, campaign.report.exported |
| Assessments | assessment.created, assessment.updated, assessment.published, assessment.deleted |
| Availability | assessment.availability.changed |
| Registration forms | registration_form.created, registration_form.updated, registration_form.published |
| Registrations | registration.accepted |
| Communications | communication.template.created, communication.template.updated, communication.template.archived, communication.created, communication.dispatch.queued, communication.status.changed, communication.retry, reminder.sent |
| Files | file.uploaded, file.downloaded, file.deleted |
| Account and identity | account.self_joined, auth_entry.accepted, auth_entry.campaign_joined, user.profile.updated, user.preferences.updated |
| Diagnostics | webhook.test |
Three of them are delivered after their transaction commits. The rows behind
registration.accepted, communication.created and
communication.dispatch.queued are written inside a database transaction beside
an audit record, so their fan-out cannot run in the same breath as the write: it
runs immediately after the commit instead. That is invisible on the wire — the
POST, the body and both signatures are identical to every other event — with one
consequence worth knowing. If Assessor.io cannot reach its own delivery queue at
that moment, the write still stands and no delivery is ever queued for it: there
is no retry, because a retry would mean holding a registration acceptance open
until a webhook queue answers. Treat these three as at-most-once and reconcile
against the resource if you need certainty.
registration.accepted fires on the FIRST transition into Accepted only. The
acceptance letter, the invitation it mints and this event are one decision, so
re-accepting an already-accepted submission raises nothing.
Editing a subscription. isActive is mutable through PATCH, and the
signing secret can be rotated in place from the account's webhook screen
(POST /api/v1/accounts/{accountId}/webhooks/{subscriptionId}/rotate-secret),
which returns the new value once and keeps the subscription and everything
already queued for it. The URL and the event list still cannot be changed in
place — delete the subscription and register a new one.
Rotating a secret, from the receiver's side. Pending deliveries are signed at SEND time, not at enqueue time, so a rotation takes effect on the next attempt of every delivery including ones already waiting. Both signature headers derive from the one secret and rotate together. Accept both the old and the new secret for as long as the queue can be behind — the retry ladder tops out at seven minutes — then drop the old one.
Debugging a delivery. The account's webhook screen carries a per-subscription
delivery log (status, attempt count, your endpoint's own error, when the next
attempt is due, and the eventId), a test send that raises a real
webhook.test event through the same queue every other event uses, and a
re-fire for a delivery that exhausted its attempts. The re-fire keeps the
delivery id, so a receiver that saw the failed attempts recognises it as the
same delivery.
Errors
| Status | Meaning |
|---|---|
400 | Malformed body or an invalid filter value |
401 | Missing, malformed, or unrecognised bearer token |
403 | Token not scoped to this account, or missing the required scope |
404 | Resource does not exist or belongs to another account — the API does not distinguish the two, so you cannot probe for ids outside your account |
429 | Per-token rate limit exceeded; honour the Retry-After header |
500 | Unexpected server error |
Related pages
- MCP Server — the same eleven operations wrapped as tools for an LLM agent, with the token held server-side.
- Embed an assessment or its results —
the browser-side path: origin allowlists, embed tokens, and why
asr:completeis not a completion record.