Skip to main content

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:

  1. A service token. Tokens are minted at /console/api-credentials in 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 older AGENT_SERVICE_TOKENS_JSON environment path is deprecated and being retired; it is honoured only so existing holders keep working.)
  2. The account ids the token is scoped to. A token that reaches no account is refused at issuance rather than 403ing on every call.
  3. The scopes your integration needs (see the table below). Scopes are not inferred from each other — a token with campaigns:read cannot 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 learn asr_ 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 with 401. Mint a replacement at /console/api-credentials before 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:

StepFailure
Resolve the principal from the token401 Unauthorized
Check the token is scoped to the accountId in the path403 Forbidden
Check the token holds the operation's required scope403 Forbidden
Check the token is inside its per-minute budget429 + Retry-After seconds

Operations​

Base path: /api/v2/public/accounts/{accountId}

OperationMethod and pathScope
List campaignsGET /campaignscampaigns:read
Read a campaignGET /campaigns/{campaignId}campaigns:read
Read the assessment inventory (the codebook)GET /campaigns/{campaignId}/assessmentscampaigns:read
List participantsGET /campaigns/{campaignId}/participantsparticipants:read
Upsert a participantPOST /campaigns/{campaignId}/participantsparticipants:write
List submissionsGET /campaigns/{campaignId}/submissionssubmissions:read (+ submissions:read:answers for answer content)
Export resultsPOST /campaigns/{campaignId}/exportsresults:export
List CRM contactsGET /contactscontacts:read
List webhook subscriptionsGET /webhookswebhooks:manage
Register a webhookPOST /webhookswebhooks:manage
Activate or deactivate a webhookPATCH /webhooks/{subscriptionId}webhooks:manage
Unsubscribe a webhookDELETE /webhooks/{subscriptionId}webhooks:manage
Search Action StatementsGET /retrieval?types=statementstatements:read
Search library items / prior template questionsGET /retrieval?types=item,template_questionstatements:read
List templatesGET /templatestemplates:read
Read a templateGET /templates/{templateId}templates:read
Validate a template body (no write)POST /templates/validatetemplates:read
List answer scalesGET /scalestemplates:read
List labelsGET /labelstemplates:read
Start a Blueprint from pasted textPOST /blueprintsblueprints:write
Read a BlueprintGET /blueprints/{blueprintId}templates:read
Check a Blueprint's readiness (no write)POST /blueprints/{blueprintId}/validatetemplates:read
Run the BIMei AI Assistant over a BlueprintPOST /blueprints/{blueprintId}/aiablueprints:write
Compile a Blueprint into a Draft templatePOST /blueprints/{blueprintId}/compileblueprints: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:

ResourceExcluded, and why
CampaignThe access passcode value (only the enablePasscode boolean is exposed), shareWithAllAccountUsers, branding, and the raw settings object
ParticipantThe linked Assessor.io userId, avatar URL, and assigned-auditor records
SubmissionAnswer content, unless the token holds submissions:read:answers
ExportAuditor review data — verdicts, corrected-value markers, reviewer keys and notes — unless the token holds submissions:read:answers
ContactThe free-form metadata bag (internal notes, captured form messages, sync correlation ids), the owning user, the source reference, and the archive timestamp
WebhookThe 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 /submissions returns answers only if the token holds submissions:read:answers in addition to submissions:read. A metadata-only token can watch completion rates without ever seeing a response. meta.answersIncluded tells you which you got, and when it is false meta.reason says why — withholding answers is an ordinary 200 with the same page size and no answers key 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: false for 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.
  • POST /exports always contains real answers. results:export is 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.draftsExcluded says how many rows the default left out of this listing, counted after your other filters. 0 there 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_progress included, so it remains the way to ask for the drafts alone.
  • ?includeDrafts=1 restores the whole pre-1.1.0 population in one call, the way filters.includeDrafts does on the export. Only 1 and true count; ?includeDrafts=false keeps 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:

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

  • 201 when a participant was created, 200 when one was updated.
  • meta.created says which happened.
  • metadata is 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): takerLink and synthetic. takerLink.epoch is the generation a signed taker link is checked against — overwrite it and every link somebody regenerated becomes valid again — and synthetic.runId is how a manufactured test cohort is identified and purged. A body naming either is still a 200/201; the stored values simply stay as they are, and meta.preservedKeys lists what was refused. It is not a 400, because re-sending the whole bag you read a minute ago is the ordinary case for a sync.
  • A key sent as null is deleted (v2 1.2.0). Posting metadata: {"crm_id": null} removes crm_id from 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. Only null deletes — false, 0 and "" 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 PATCH has 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.segments is 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 null for takerLink or synthetic removes nothing and is reported in meta.preservedKeys. A removal is a write.
  • What you read back in data.metadata is 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.

  • xlsx arrives as export.workbookBase64 with export.sheetNames.
  • csv and json arrive as export.content; export.contentType is on every arm.
  • export.rowCounts and export.population say which rows the file holds, export.codebook documents every generated column, export.shapeReport is the per-question census of answer shapes, and export.notes carries 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>::original appears only where a correction actually changed the value, and holds what the respondent wrote.
  • rev:<question>::status, ::modifiedApplied, ::locked, ::reviewedAtIso, ::reviewerKey, ::reviewLevel and ::reviewLevelLabel describe the review. A question nobody reviewed gets none of them, so an unreviewed campaign is no wider than before. ::reviewLevel is the auditor's account-configurable review-level label id (D-174 — not a computed value); ::reviewLevelLabel is that label's current display text, resolved at export time.
  • Every row carries reviewCount, reviewVerifiedCount, reviewModifiedCount, reviewLockedCount, reviewNotesExportedCount, reviewNotesWithheldCount and reviewLastReviewedAtIso.
  • Every row carries syntheticRunId, syntheticGenerator and syntheticSeed — blank for a real submission — so a downloaded file can be reconciled against a purge by run.
  • export.notes (a Notes sheet in xlsx) carries the auditor's own words. A review the auditor unticked include in export on contributes nothing here; reviewNotesWithheldCount is 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 a matrix or multipletext, and ans:<question>::<row>::<column> for a matrixdropdown — never one JSON blob per question. The json arm of an export additionally carries the raw object under answers, 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 ::raw escape hatch for a value whose shape does not fit the plan, appear in a file and cannot appear in a questionnaire-only projection. export.shapeReport is 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.

HeaderCoversUse it when
X-Assessor-SignatureHMAC-SHA256(secret, raw body)You already implemented it. Unchanged, still sent, not going away without notice
X-Assessor-Signature-V2t=<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.

GroupEvents
Participantsparticipant.joined, participant.completed_all
Submissionsassessment.submission.created
Campaignscampaign.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
Assessmentsassessment.created, assessment.updated, assessment.published, assessment.deleted
Availabilityassessment.availability.changed
Registration formsregistration_form.created, registration_form.updated, registration_form.published
Registrationsregistration.accepted
Communicationscommunication.template.created, communication.template.updated, communication.template.archived, communication.created, communication.dispatch.queued, communication.status.changed, communication.retry, reminder.sent
Filesfile.uploaded, file.downloaded, file.deleted
Account and identityaccount.self_joined, auth_entry.accepted, auth_entry.campaign_joined, user.profile.updated, user.preferences.updated
Diagnosticswebhook.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​

StatusMeaning
400Malformed body or an invalid filter value
401Missing, malformed, or unrecognised bearer token
403Token not scoped to this account, or missing the required scope
404Resource does not exist or belongs to another account — the API does not distinguish the two, so you cannot probe for ids outside your account
429Per-token rate limit exceeded; honour the Retry-After header
500Unexpected server error
  • 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:complete is not a completion record.