Embed an assessment or its results
Purpose
Put an Assessor.io assessment, or a campaign's published results, inside an iframe on your own website — so a respondent never leaves your page, and a programme summary sits inside your own report.
This is the third external-consumption path, beside the public API (v2) and the MCP server. It is the only one where the browser, not a token, is the thing being trusted, so most of this page is about who is allowed to frame what.
The two embeddable addresses
| Surface | Address | What it is |
|---|---|---|
| Assessment | /embed/a/{assessmentKey} | The taker, chromeless and branded — the same assessment as /a/{assessmentKey} |
| Results | /embed/results/{publicKey} | The published-results widget for a campaign that has published |
Nothing else under /embed/ is a framed surface. Anything else there is served
frame-ancestors 'none' and will not load in a frame at all.
Who may frame it — the part that decides everything
Framing is refused by default, and there is no setting on the page that changes
that. The decision is a Content-Security-Policy: frame-ancestors response
header, resolved per request in src/proxy.ts:
- Register your origin on the campaign. An origin is a scheme, host and
port —
https://example.org, nothttps://example.org/programme/and notexample.org. Ports matter, and so doeshttpsversushttp. - The registered list becomes the directive. An empty list is
frame-ancestors 'none': an unregistered origin gets a browser refusal, not a login screen or an error page you can read. - The results widget uses the PUBLISHING CAMPAIGN's list. Publishing results is not itself permission to frame them, so a widget is embeddable exactly where that campaign's assessments are.
If the frame is blank and DevTools reports a CSP violation, the origin in the message is the exact string that must be registered.
Narrowing with an embed token
POST /api/v2/public/accounts/{accountId}/embed-tokens (scope embed:issue)
mints a short-lived token for one assessment and one origin. Append it as
?t=<token>.
A token narrows the policy and never widens it. The campaign's allowlist is still consulted and the effective set is the intersection of the two, so:
- a token cannot admit an origin the campaign has not registered — the minting route refuses to sign one, and the proxy is the second lock on that door;
- a token issued for client A's page stops the same assessment being framed by client B, even though both origins are registered — which is the whole value;
- an invalid or expired token leaves the open-tier policy in place rather than granting anything. It never turns a refusal into an admission.
The page-to-host contract (postMessage), protocol v2
The framed page posts asr:* messages to its host so you can size the frame and
react to progress. The contract lives at src/lib/embed/postMessage.ts.
Every message, both directions, is the same envelope:
{ source: 'asr', version: 2, type: string, payload?: object }
Check source and version before you read anything else. A host page is a busy
place — analytics tags, chat widgets, video players and consent managers all post
on the same window, several of them sending bare strings or objects with a
type.
Frame to your page
| Type | Payload | Meaning |
|---|---|---|
asr:ready | assessmentKey | The assessment is mounted and interactive. |
asr:resize | height | Content height changed; size the iframe to it. |
asr:progress | answered | The respondent advanced. A count, never answers. |
asr:complete | submissionId | Finalised — but read the warning below. |
asr:error | code | Terminal failure, coarse code only. |
Your page to the frame
| Type | Payload | Meaning |
|---|---|---|
asr:host | lang (optional) | Handshake. It tells the frame which origin to reply to. |
Always check event.origin against the Assessor.io origin you framed before
acting on a message.
<iframe id="asr" src="https://assessor.bimexcellence.org/embed/a/KEY?t=TOKEN"
style="width:100%;border:0" title="Assessment"></iframe>
<script>
const ASR_ORIGIN = 'https://assessor.bimexcellence.org';
window.addEventListener('message', (event) => {
if (event.origin !== ASR_ORIGIN) return;
const message = event.data;
if (!message || message.source !== 'asr' || message.version !== 2) return;
if (message.type === 'asr:resize') {
document.getElementById('asr').style.height = `${message.payload.height}px`;
}
});
</script>
If you use embed.js, you listen for DOM events instead
The drop-in script at https://assessor.bimexcellence.org/embed.js injects the
iframe, handshakes, resizes it for you, and re-dispatches everything else as a
CustomEvent on your container — so the names above are the event names you
listen for, and event.detail is the payload:
<div id="asr-embed"></div>
<script src="https://assessor.bimexcellence.org/embed.js"
data-assessment-key="ABCD123456"
data-target="#asr-embed"
data-lang="en"></script>
<script>
document.getElementById('asr-embed')
.addEventListener('asr:complete', (event) => {
console.log('submission', event.detail.submissionId);
});
</script>
What changes for existing integrations
Protocol v1 said source: 'aio', version: 1 and named the same six events
aio:ready, aio:resize, aio:progress, aio:complete, aio:error and
aio:host. That spelling is deprecated — the platform's old three-letter
shorthand now means something else inside BIMei — and it is removed on
2026-12-31. Until then nothing you have written stops working:
- The frame sends every event twice, once as the v2 envelope and once as the
v1 envelope it always sent. A listener filtering on the v1
source: 'aio'andversion: 1keeps receiving exactly what it received before, including resize and completion. embed.jsdispatches both DOM event names. A container listening foraio:completestill fires, and so does one listening forasr:complete. Each fires once per event, not twice — the script drops the duplicate itself.- The handshake is accepted in either spelling, so a page that hand-rolled an
aio:hostmessage, or one still running an older self-hosted copy ofembed.js, needs no change today.
What to do before 2026-12-31: change your envelope filter to
source === 'asr' and version === 2, and rename the aio:* event names in
your listeners to their asr:* equivalents. There is nothing else — the
payloads, the origin rules and the tokens are untouched.
After that date the frame sends only the v2 envelope, embed.js dispatches only
the asr:* event names, and a v1 listener goes quiet.
asr:complete is not a completion record
It runs on your page, which means anything on your page can send it. Treat it as a UX signal — swap the frame for a thank-you, advance your own wizard — and never as the fact that a submission exists.
The trustworthy completion channel is the webhook: signed, server-to-server, retried, and carrying a delivery id you can deduplicate on. See Webhooks for the payload and the signature.
Walk-up participation
An embedded taker finalises into a campaign-bound assessment: a respondent who arrives through your page, with no invitation and no account, is bound to the campaign on submit rather than being refused. That is what makes the embed a data-collection surface rather than a preview.
Related pages
- Public API (v2) — tokens, scopes, webhooks, and the signature you verify a completion with
- MCP Server — the same operations as agent tools