Build the Intelligent Wellbeing Engine into your product
Everything an organisation needs to add the IWE check-in for its people and give employers honest, anonymity-protected results back. Every endpoint, payload and rule on this page comes from the running reference build.
The IWE in one minute
The IWE (Intelligent Wellbeing Engine) is a licensable wellbeing measurement engine. Your product (an HR platform, a care-rota app, an ops console, an intranet, anything with a logged-in workforce) embeds a short fortnightly check-in that the IWE hosts and scores. In return, the employer gets organisation-level results with confidence intervals, trends across rounds, and a standards-based export.
Three properties define the integration. They are worth reading before you write any code, because they explain most of the design decisions you will meet below:
The engine never learns who anyone is
You send an opaque user ID; the gateway scrambles it with a secret key before storage. There are no name columns in the database, and adding one is defined as a licence-breaking change.
Individuals are never reported
Results resting on fewer than 5 people are withheld by a database CHECK constraint. No setting, parameter or support ticket reveals them.
Your secret never reaches a browser
All partner API calls happen server to server. The browser only ever holds a ten-minute, single-purpose embed token for one person's check-in.
How the pieces fit together
You integrate at two points. Your backend talks to the IWE gateway using OAuth2 client credentials. Your front end shows an iframe whose URL your backend requested. The employee-facing survey screens are IWE-hosted: you never build question pages, scoring, or crisis signposting.
The rest of this guide has four parts. Follow them in order the first time; each part stands alone afterwards.
Part 1. Get connected
Environments and base URLs
| Sandbox | Production (per licensee) | |
|---|---|---|
| API base | https://sandbox.wellbeingengine.io | Given to you at go-live |
| Embed origin | The origin of the embed_url the API returns | The origin of the embed_url the API returns |
| Credentials | Self-serve, instant | Issued automatically at go-live; the secret is shown once |
| Scopes | checkin:write, aggregates:read, usage:read | All five, including respondents:write and webhooks:manage |
| Workforce | Four synthetic units: syn-normal, syn-tiny, syn-sleep, syn-lowcomp | Your real roster, synced by you |
| Lifetime / quota | 30 days, 2,000 writes (embed-token requests and answers sent through the API) | Contractual |
| Iframe policy | No frame-ancestors restriction | frame-ancestors locked to your origins |
The four synthetic units are seeded to show four honest reporting behaviours: a normal workforce, a below-floor group whose figures are all withheld, a declining sleep signal, and a low-completion round. That means you can build and test every rendering state, including suppression, before you ever touch real data.
Question wording in the sandbox is placeholder wording. The sandbox
reads questions through the iwe.items_display view, which swaps
the wording the IWE authored for a plausible placeholder and marks the item
with is_placeholder: true. Wording from published instruments is
shown as it is. Production shows the real wording. Build your screens around
the fields, not the exact text you see in the sandbox.
Get your keys
Sandbox credentials are self-serve. One POST, with no contract and no human in the loop:
POST /sandbox/signups
Content-Type: application/json
{ "email": "you@example.com" } // optional; any other field is ignored
// 201 - the secret is shown ONCE, store it now
{
"client_id": "sbx_<18 hex>",
"client_secret": "iwe_sbx_<48 hex>",
"scopes": ["checkin:write", "aggregates:read", "usage:read"],
"expires_at": "<now + 30 days>",
"quota_answers": 2000,
"token_url": "/oauth/token",
"synthetic_units": ["syn-normal", "syn-tiny", "syn-sleep", "syn-lowcomp"],
"synthetic_respondents": ["syn-normal-01", "syn-normal-02", "syn-normal-03"],
"checkin_ready": true // the syn-normal unit has an open round
}
Signups are rate limited to 3 per 10 minutes and 5 per day per IP address
(you get a 429 with retry_after_seconds). Sandbox
keys deliberately do not carry respondents:write, so in
the sandbox you work only with the synthetic roster and cannot create or
erase respondents. The response lists three synthetic people,
syn-normal-01 to syn-normal-03. The seeded
syn-normal unit holds twelve, syn-normal-01 to
syn-normal-12, and any of them works as a
partner_user_id.
Production keys (iwe_sk_...) are provisioned per licensee with
your agreed scopes. Secrets are stored server-side only as scrypt hashes; if a
secret ever leaks, the client is deactivated as a kill switch and a new key is
issued.
Get an access token
POST /oauth/token
Content-Type: application/json
{ "grant_type": "client_credentials",
"client_id": "sbx_...", "client_secret": "iwe_sbx_...",
"scope": "checkin:write aggregates:read" } // optional narrowing
// 200
{ "access_token": "<RS256 JWT>", "token_type": "Bearer",
"expires_in": 3600, "scope": "checkin:write aggregates:read" }
The access token lives one hour. Cache it and refresh a couple of
minutes early rather than requesting one per call, because token issuance is
rate limited (30 per minute per client, 120 per minute per IP). Send it on
every partner call as Authorization: Bearer <token>. A
missing or wrong scope returns
403 { "error": "insufficient_scope", "required": "<scope>" }.
Token signatures are RS256 and verifiable offline: the public keys are
published at GET /.well-known/jwks.json. The machine-readable
endpoint reference is the OpenAPI spec at
/docs/openapi.yaml.
| Scope | Grants |
|---|---|
respondents:write | Roster sync, erasure, launching survey rounds |
checkin:write | Reading a person's check-in state, submitting answers, requesting embed tokens, reminder eligibility |
aggregates:read | Rounds list, results feed, OWHS export |
webhooks:manage | Registering webhooks (production keys only; sandbox keys do not carry it) |
usage:read | Monthly enrolled-respondent count (the billing metric) |
Prove it works in five minutes
- Sign up: POST
https://sandbox.wellbeingengine.io/sandbox/signups. Save the secret immediately. - Get a token: POST
/oauth/tokenwith your client credentials. - Check state: GET
/respondents/ext:syn-normal-01/checkin. Thesyn-normalunit keeps an open round. - Request an embed token: POST
/embed/tokenswith{"respondent_ref":{"partner_user_id":"syn-normal-01"}}, then load the returnedembed_urlin a browser and complete the three questions. - Read results: GET
/aggregates?unit_id=syn-normal. Then trysyn-tinyto watch the anonymity rule doing its job: every figure withheld, none carrying a value.
Build it with your AI assistant
Paste this into Claude Code, Cursor or any coding assistant that can read the web. It points the assistant at this guide and the OpenAPI spec and builds against the sandbox, with synthetic people only.
Integrate the Intelligent Wellbeing Engine (IWE) into this codebase, against its sandbox. Read these first and treat them as the source of truth: - Integration guide: https://wellbeingengine.io/guide - OpenAPI spec: https://wellbeingengine.io/docs/openapi.yaml Then do this, one step at a time: 1. Get sandbox keys with POST https://sandbox.wellbeingengine.io/sandbox/signups. Put client_id and client_secret in server-side environment variables. Never commit them. Never send them to the browser. 2. On the server, swap the keys for a bearer token at POST /oauth/token (client credentials). Cache the token for about 55 minutes. 3. Add a server route that requests an embed token (POST /embed/tokens) for the signed-in user. Identify people only by a stable opaque partner_user_id, never a name or an email address. In the sandbox, use the synthetic ids, for example syn-normal-01. 4. On our wellbeing page, open the returned embed_url in a plain iframe. There is no package to install. Act on a message only if e.origin is the embed_url's origin, e.source is the iframe's contentWindow and e.data.source is "iwe-embed". The first completed event means the answers are saved: keep the frame open, because the thank-you screen shows crisis signposting. Remove the frame on the second completed event (the person pressed Close) or on dismissed. Add your own Close button next to the frame, because an expired token shows an error and sends no message. 5. Add an employer results view that reads GET /aggregates. Show the confidence interval beside every value. Show a provisional badge when method is "indicative". Render suppressed rows as withheld, with their reason and no value anywhere in the page. 6. Use the certification markup (the data-iwe-* attributes in the guide) so the automated conformance check passes. Use synthetic sandbox data only. Ask me before you change anything outside the files this needs, and explain each change as you make it.
Production keys come later, once your integration passes certification. See every step to going live.
Part 2. Your people: the employee side
How the engine stores an organisation and a person
Organisations are flat units. Each employer is one unit, keyed by
your identifier (external_ref). A licensee serving many
employers has one unit per employer. Units deliberately do not nest: there is
no sub-organisation, department or team slicing anywhere in the engine. That
is part of the anonymity design.
People are pseudonyms. You identify a person only by a stable, opaque
partner_user_id (your own user ID, never an email address or
name). Before anything is stored, the gateway computes:
pseudonym = HMAC-SHA256(per-deployment secret salt, partner_user_id)
The salt lives in a secrets vault and is never in the database. As a
result, the mapping only exists on your side. If you
change a user's ID in your system, the engine sees a brand-new person.
Wherever an endpoint takes :respondentId, pass
ext:<partner_user_id> (a raw engine UUID also works, but in
practice you will always use the ext: form).
Sync your roster production · respondents:write
POST /respondents:sync
Authorization: Bearer <access token>
{ "respondents": [
{ "partner_user_id": "u-1042",
"unit_id": "acme-ltd", // YOUR external_ref for the unit
"email": "optional - only if IWE operates email delivery for you",
"bands": { "age_band": "35-49", "tenure_band": "3-5y", "work_pattern": "full-time" },
"status": "active" }
] }
// max 500 rows per call - 422 { "error": "too_many_rows" } beyond that
// 200 - partial success with per-row errors
{ "upserted": 499, "errors": [ { "index": 12, "code": "...", "message": "..." } ] }
Demographics are optional and restricted to three banded keys
(age_band, tenure_band, work_pattern),
validated against open OWHS codelists. Free-text or identifying attributes are
rejected at the database layer.
Erasure (right to be forgotten):
DELETE /respondents/ext:u-1042 returns
202 { "accepted": true }, cascades through answers, delivery
records and follow-up questionnaires, writes an erasure receipt (a fingerprint
and row count, not the data), and emits a respondent.erased
webhook so your side can reconcile.
Add the check-in to your product
The complete flow, end to end:
- Your backend requests an embed token for the signed-in user.
The embed token is hard-capped at 10 minutes and can only drive that user's check-in. Request it at the moment the user opens the check-in, not at page load. APOST /embed/tokens Authorization: Bearer <access token> // scope checkin:write { "respondent_ref": { "partner_user_id": "u-1042" } } // 200 { "token": "<RS256 JWT, scope embed:checkin>", "embed_url": "https://sandbox.wellbeingengine.io/checkin?token=...", // always use the value returned "expires_at": "<now + 10 minutes>" }404means no such respondent exists in the engine (not yet synced). - Your page opens the embed URL in an iframe. There is no package
to install; a few lines of your own code do it:
Accept only messages whose origin is the embed origin and whose payload isconst frame = document.createElement('iframe'); frame.src = tok.embed_url; // fetched from YOUR backend frame.referrerPolicy = 'no-referrer'; frame.style.cssText = 'width:100%;min-height:520px;border:0'; document.querySelector('#wellbeing').appendChild(frame); const embedOrigin = new URL(tok.embed_url).origin; let answersSaved = false; window.addEventListener('message', (e) => { if (e.origin !== embedOrigin || e.source !== frame.contentWindow) return; if (e.data?.source !== 'iwe-embed') return; if (e.data.type === 'dismissed') { frame.remove(); return; } if (e.data.type === 'completed') { if (answersSaved) { frame.remove(); } // the person pressed Close answersSaved = true; // thank-you screen is showing: leave it open } });{ source: "iwe-embed", type: "completed" | "dismissed" }. Keep the frame open after the firstcompleted. It arrives when the answers are saved and the thank-you screen appears, and that screen carries the crisis signposting. The check-in sendscompletedagain when the person presses Close; remove the frame then, or ondismissed. Put your own Close button next to the frame as well: if the embed token has expired, the check-in shows an error and sends no message, and the person still needs a way out. Where you place the frame is entirely yours: the partner showcase runs the same engine in a persistent right rail, a full-screen mobile sheet, a modal over a dense console, and an inline intranet tile. - The IWE-hosted page runs the survey. Three questions, worded answer options, WCAG 2.2 AA accessible. Answers save one at a time, so closing part-way loses nothing. The final screen is a scoreless thank-you with crisis signposting (Samaritans, Mind, EAP) that is baked in and never configurable by the host. The employee never sees a wellbeing score, trend or comparison. That is deliberate, so the check-in can never become a performance surface.
- Your page reacts to the result event: close the modal, show your own thank-you, refresh your "check-in due" card. Treat the events as UI hints, not as trusted state; the source of truth for completion is the API (next section).
/oauth/token or /embed/tokens from a browser, the
design is telling you to move that call to your server. A client secret in a
browser is the one integration mistake the architecture refuses to allow.Alternative for native apps and kiosks checkin:write:
if you cannot use an iframe, the same survey can be driven from your server.
Read the state with GET /respondents/ext:u-1042/checkin and submit
each answer with POST /respondents/ext:u-1042/answers using
{ cycle_id, question_code, value | band, channel }. You then own
the rendering obligations the hosted page normally covers (exact question
wording, worded options, the scoreless ending); these are checked at
certification.
The welcome survey (first contact) rolling out
Before someone's first regular check-in, the engine can run a one-off welcome survey: a slightly longer baseline in up to three sections, mirroring the flow employees get on the Alltoogether platform. Section A is the baseline question set. Sections B and C are optional extras behind explicit per-section consent.
{ "needed": false } with a machine-readable reason rather than an
error. Your integration should simply respect needed; you do not
need to know or care about the deployment flag behind it.The flow is driven by the same ten-minute embed token as the check-in, so when the IWE-hosted page runs it for you there is nothing extra to build. If you drive your own survey UI over the API, the contract is:
1. Ask whether a welcome survey is needed:
GET /embed/welcome
Authorization: Bearer <embed token>
// not needed - carry straight on to the regular check-in
{ "needed": false, "reason": "baseline_complete" }
// other reasons: "disabled" (deployment flag off),
// "not_deployed" (engine migration absent), "unknown_respondent"
// needed - the payload carries the sections, wording read live from the bank
{ "needed": true,
"has_open_cycle": true,
"consents": [], // sections this person has already opted into
"sections": {
"A": [ { "code": "WORK_ABILITY_1",
"text": "Right now, how would you rate your current ability to do your job?",
"scale": "NUMERIC_0_10", "reverse": false,
"options": [...], "position": 1 },
... ] } }
2. Record consent first, if the person opts into sections B or C (section A never needs consent because it carries no special-category questions):
POST /embed/welcome/consent
Authorization: Bearer <embed token>
{ "sections": ["B"], "wording_version": "welcome-consent-v1" }
// 200 { "status": "recorded", "sections": 1 }
// 422 invalid_sections | wording_version_required
The wording_version is what makes the consent record
evidential: it pins exactly which wording the person agreed to. Consent is
idempotent (the first timestamp is the one that is kept) and the record is
deleted with the respondent on erasure.
3. Submit a section (one call per section, partial answers allowed):
POST /embed/welcome/sections
Authorization: Bearer <embed token>
{ "section": "A",
"answers": { "WORK_ABILITY_1": 7, "BURNOUT_1": 3, "SLEEP_QUALITY_1": 6 } }
// 200
{ "status": "recorded", "section": "A", "items_written": 3,
"counted_into_cycle": true, "submission_id": "<uuid>" }
Rules worth designing around:
- Section A counts as a check-in. Its answers are written into the open round exactly like a pulse submission, so completing the welcome survey also completes that round's check-in and feeds that round's results. The one-answer-per-question rule still holds: if the person already answered a question in this round, the earlier answer wins.
- Section A is the baseline marker. Once recorded,
GET /embed/welcomereturnsbaseline_completeforever; the welcome survey is one-off by design. - Sections B and C refuse loudly.
Submitting a section that is not configured on the deployment, or one
the person has not consented to, returns
409 { "error": "welcome_section_unavailable" }with a human-readable detail. A deployment without the welcome survey machinery at all returns404 { "error": "welcome_survey_not_deployed" }. - Answers are validated against each question's scale
(
422 value_outside_scaleotherwise), and audit records store the shape of a submission (which questions, how many), never the values.
The partner showcase demonstrates all of this: the live flow where a deployment serves it, the engine's own degrade reasons where it does not, and a labelled preview of section A.
How the engine knows when the next survey is due
Timing is organised around fortnightly rounds (the API calls them cycles), not per-user timers:
- A round belongs to a unit and runs 14 days. Rounds never overlap: the next one can only start once the current one has closed, and round numbers count up per unit.
- Each round asks three questions: two fixed anchors (work ability and burnout) plus one rotating question chosen per person, least recently answered first, from a pool of ten (job satisfaction, workload, intention to leave, overall health, sleep, financial confidence, benefits awareness, musculoskeletal pain, fatigue, alcohol use). Ten rotators at one per fortnight means every domain refreshes roughly every five months without ever burdening anyone with a long form.
- One submission per person per question per round is enforced by a unique
database index. Re-submitting returns
{ "status": "duplicate" }and changes nothing.
The "due now?" check is one call per user:
GET /respondents/ext:u-1042/checkin
Authorization: Bearer <access token> // scope checkin:write
// nothing due - the user is all caught up
{ "enabled": true, "cycle": null }
// a round is open and this person has questions left
{ "enabled": true,
"cycle": {
"id": "<uuid>", "cycle_number": 7,
"closes_at": "2026-08-21T09:00:00Z",
"answered": 1, "total": 3, "complete": false,
"items": [
{ "code": "WORK_ABILITY_1", "position": 1,
"question_text": "Right now, how would you rate your current ability to do your job?",
"response_scale": "NUMERIC_0_10", "response_options": [...],
"answered": true },
...
] } }
cycle: null means show nothing. complete: false
means show your check-in entry point. closes_at tells you how
long the window stays open. There is no separate per-user schedule to track:
when the next round opens, this endpoint starts returning it, and the
cycle.opened webhook tells you the moment that happens so you can
surface the prompt without polling.
Starting and listing rounds:
POST /cycles:launch // scope respondents:write
{ "unit_id": "<unit uuid>" }
// { "status": "launched", "cycle_id": "..." } | { "status": "already_launched" }
// | { "status": "roster_too_small", "roster": 4 } - n >= 5 enforced at launch
GET /cycles?status=open|closed|all // scope aggregates:read
// [ { "id", "unit_id", "cycle_number", "opens_at", "closes_at", "status" } ]
// unit_id here is the engine's unit UUID, not your external_ref
Notice the floor appears here too: a unit with fewer than five active people cannot even start a round, so there is never a round whose results would be unreportable by definition.
Invites and reminders (if your product sends the "your check-in is open" messages): the engine keeps you honest about frequency. One invite and at most one reminder per person per round, reminders only after 48 hours:
GET /respondents/ext:u-1042/reminder-eligibility
// { "eligible": true, "kind": "invite" | "reminder" | "none",
// "reason": "ok" | "not_yet_invited" | "reminder_window_not_open"
// | "already_reminded" | "already_answered" | "no_open_cycle" }
POST /respondents/ext:u-1042/nudges:record
{ "kind": "invite", "channel": "host_push" }
// { "recorded": true } - 409 { "error": "no_open_cycle" } outside a round
What gets saved, and where
Every answer is split across separate stores by sensitivity, which is why the privacy promises are structural:
| Store | Holds | Who can read it |
|---|---|---|
| Responses (individual, sensitive) | Pseudonymous respondent, question code, the value or band | Only the aggregate computation and the respondent's own live session. No employer or partner read path exists. |
| Answer ledger | Which questions are answered per round, and the channel; no values | Drives answered / total / complete and the duplicate guard |
| Aggregates (organisation-level) | Per-round, per-unit results with counts, confidence intervals and suppression flags | You, via GET /aggregates. This is the employer surface. |
| Audit log | Every authenticated gateway action | IWE operations; it exists to prove the negative: that no individual-level query ever ran |
After each completed pulse the engine also evaluates thresholds privately. A sustained low sleep score, for example, can offer that individual a validated follow-up questionnaire and route them to support they already have. All of that stays between the engine and the individual: follow-up scores are never aggregated for the employer. The single exception is a severe-distress count with a stricter n ≥ 10 floor, enforced by its own CHECK constraint. The whole database is deny-by-default with the gateway as its only client, so a leaked database key reads nothing.
Part 3. The results: what the employer gets back
The results feed
GET /aggregates?cycle_id=<uuid>&unit_id=<external_ref>&metric=<metric>
Authorization: Bearer <access token> // scope aggregates:read
// all three filters optional
// 200 - one row per unit x round x metric (x question for DOMAIN_MEAN)
[ { "cycle_id": "<uuid>", "unit_id": "<unit uuid>",
"metric": "WELLBEING_INDEX",
"question_code": null,
"n": 11, "headcount": 12, "completion_rate": "0.917",
"suppressed": false, "suppression_reason": null,
"value": "71", "ci_low": "63.4", "ci_high": "78.6", "sd": "11.20",
"method": "design", "index_version": "v1-equal",
"computed_at": "2026-08-07T02:00:11Z" },
{ "metric": "DOMAIN_MEAN", "question_code": "SLEEP_QUALITY_1",
"n": 4, "suppressed": true, "suppression_reason": "below-floor",
"value": null, "ci_low": null, "ci_high": null, "sd": null, ... } ]
Parse the numbers. value, ci_low,
ci_high, sd and completion_rate arrive as
JSON strings holding decimals (for example "63.4"), and
null when withheld. Convert them with Number() or a
decimal library before you do arithmetic. n and
headcount are numbers. The unit_id query parameter
takes your external_ref; the unit_id in each row is
the engine's unit UUID.
| Metric | What it is |
|---|---|
WELLBEING_INDEX | The headline. Each person's answers normalised 0 to 100 (reverse-scored questions flipped), combined equally, then averaged across the unit with a t-based 95% confidence interval. |
DOMAIN_MEAN | One row per pulse question, same normalisation: the "by question" table. Rows carry the question code; the exact wording is available from the check-in payload so dashboards can show what was asked. |
PARTICIPATION | Reserved for response rate as its own metric. It is not computed today; use n, headcount and completion_rate on each row. |
BENEFITS_REACH | Defined for the routing layer: of people with an elevated signal, the share who accepted a route to support, with safeguarding routes excluded from both sides. The database accepts it, but nothing computes it for the results feed yet, so today's feed carries WELLBEING_INDEX and DOMAIN_MEAN rows only. |
Trends are simply these rows across rounds: pull all
WELLBEING_INDEX rows for a unit, join GET /cycles
for round numbers and dates, and plot the value with its interval band per
round. Participation context rides on every row as n,
headcount and completion_rate: counts only, never
names.
Reading a row correctly
- design means n ≥ 10, a
designed estimate. provisional
(
method: "indicative") means 5 ≤ n < 10; you must badge it as a small group in any UI. - Always show the confidence interval beside the value. A low-completion round reads as indicative of the people who responded, not as a measure of the whole workforce.
suppressed: truerows still arrive, withnand a reason butnullvalues. The next section is the contract for handling them.
The honesty rules (what "suppressed" means for your code)
suppressed: true,
a machine-readable reason, and null in every value field. That is
enforced by a CHECK constraint on the results table itself, so a
below-threshold number never exists to be leaked, cached or subpoenaed.
Weakening that constraint is defined in the licence as termination.- The n ≥ 5 floor.
suppression_reason: "below-floor". Applies to every metric, every question, every round. - Show every refusal. Suppressed rows are present in the response. Render them as "withheld: fewer than 5 responses" (or the mapped reason), never as zero, never as a blank cell, and never omit the row silently. Do not emit the value element at all in your page for a suppressed row, tooltips included.
- A closed set of reasons.
below-floor,low-completion,safeguarding(the last protects a person at risk; treat it exactly like below-floor in your UI). - A stricter severe-distress floor. The only clinical-adjacent aggregate that exists anywhere is a severe-distress count with its own n ≥ 10 CHECK constraint. A below-10 row physically cannot be written.
- Clinical questions never aggregate. Mood and anxiety screener items and all follow-up instruments are flagged non-employer-reportable in the question bank. The aggregate computation filters them, and a database trigger refuses to configure any round that includes one. A licensee cannot even misconfigure their way to aggregating a clinical item.
- No sub-organisation slicing. Units don't nest, so no API shape exists that could ask for a team breakdown.
The OWHS export (standards-based reporting)
GET /aggregates/export?cycle_id=<uuid>
Authorization: Bearer <access token> // scope aggregates:read
// Content-Type: application/vnd.owhs.aggregate-report+json
{ "owhs_version": "0.1",
"media_type": "application/vnd.owhs.aggregate-report+json",
"producer": { "producing_system": "...", "system_version": "...", "region": "..." },
"codelists": { "suppression-reason": "...", "sampling-design": "...",
"construct-domain": "...", "construct-domain.x-iwe": "0.1.0-provisional" },
"reports": [
{ "org_unit_ref": "acme-ltd",
"period": { "start": "...", "end": "...", "cycle_number": 7 },
"sampling_design": "rotating-subset",
"metric": "WELLBEING_INDEX",
"construct_domain": null, // null on index rows; set on DOMAIN_MEAN rows
"instrument_citation": null, // DOMAIN_MEAN rows carry a citation, never item wording
"n": 11, "headcount": 12, "completion_rate": 0.92,
"method": "design", "index_version": "v1-equal",
"suppressed": false, "suppression_reason": null,
"value": 71.0, "ci": { "low": 63.4, "high": 78.6 },
"measurement_context_ref": "...", "computed_at": "..." } ] }
This is the only sanctioned exit format for taking IWE data into another
system (a BI tool, an auditor, a group-level report). It reads the same
results table as GET /aggregates, so the database's rules hold in
the file. A withheld row carries no value and no interval, and no row for
fewer than five people has a value. Instruments appear as citations, never as
question text. The export is labelled OWHS version 0.1. Its field names are the
IWE's own, and it does not validate against the published OWHS schemas, which
are now at a v0.2 draft. Every field is listed on the
open export page.
What the employer never receives, and why that is credible
Your customers will ask "but can you identify me?". The credible answer is that each guarantee maps to a database mechanism:
| Never receives | Enforced by |
|---|---|
| Any individual's answers | No API endpoint returns an individual's answers. In production the gateway will connect with a database role that cannot read raw answers. |
| Any figure built on fewer than 5 people | CHECK constraint on the results table; contractual, and weakening it terminates the licence. The export reads the same table, so it cannot contain one either. |
| Mood or anxiety screener results, in any form | Non-employer-reportable flags on the questions, filtered in computation and refused at round configuration by a database trigger. |
| Follow-up / clinical scores (PHQ-9, GAD-7, and the rest) | Individual-only tables; the sole derived aggregate is the n ≥ 10 severe-distress count. |
| Team or department breakdowns | Units do not nest; the parent column was removed from the schema. |
| Who did and did not answer | Counts only (n, headcount, completion_rate); respondents are HMAC pseudonyms. |
| Safeguarding signals (for example where the employer may be the source of harm) | Safeguarding routes resolve to external bodies only, are excluded from every aggregate including BENEFITS_REACH, and produce no employer-visible event. |
| Question wording via the export | Citation anchors only; no export path reads the question text. |
| Invented interpretation bands | Only published, sourced bands exist in the engine, and those are individual-only anyway. |
Benefits and routing: what a partner can plug in
When an individual's pulse shows a sustained signal, the engine can privately route them to support. Partners take part on the supply side:
- You can write your benefit products and the per-employer confirmed inventory (which services this workforce has), so routing offers real things. Partner and third-party services only become routable with a signed non-clinical attestation on the record.
- You cannot write routing rules or the safety-net fallbacks. The routing science and the crisis floor stay IWE-owned, and a database trigger rejects any attempt to attach a partner service to crisis or safeguarding categories.
- You read back exactly two employer-safe signals:
BENEFITS_REACH(uptake among people with an elevated signal, floored at n ≥ 5; defined, not yet in the results feed) and the benefits-awareness question mean. Individual routing events are never exposed.
Part 4. Go live
Webhooks
POST /webhooks
Authorization: Bearer <access token> // scope webhooks:manage
{ "url": "https://yourapp.example/iwe/hooks",
"events": ["cycle.opened", "cycle.closed", "aggregates.ready", "respondent.erased"] }
// 201 - signing secret shown ONCE
{ "id": "<uuid>", "signing_secret": "whsec_<48 hex>" }
| Event | Fires when | Use it to |
|---|---|---|
cycle.opened | A new round opens for a unit | Surface "check-in due" prompts without polling |
cycle.closed | The 14-day window ends | Stop prompting; expect results soon |
aggregates.ready | Suppression-checked results are computed | Refresh the employer dashboard or pull the export |
respondent.erased | An erasure completes | Reconcile your own records |
deliverability.flag | Delivery problems detected | Investigate your nudge channel |
Deliveries are JSON of the form
{ "event": "cycle.opened", "data": { "unit_id": "...", "cycle_id": "..." }, "sent_at": "<ISO>" },
signed with the header
IWE-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>.
Verify by recomputing over the raw body and rejecting timestamps
older than 300 seconds (the replay window). Respond 2xx promptly;
failed deliveries retry with exponential backoff (2ⁿ minutes, capped at
6 hours) up to 8 attempts before being marked failed.
Errors, rate limits, idempotency
Every error is a flat JSON object,
{ "error": "<snake_case_code>" }, occasionally with one
extra field (required on 403s,
retry_after_seconds on 429s).
| Status | Codes you will meet |
|---|---|
| 400 | unsupported_grant_type |
| 401 | invalid_client / missing_bearer_token / invalid_token / invalid_or_expired_embed_token / sandbox_expired |
| 403 | insufficient_scope (with required) / wrong_token_type (an embed token used on a partner route, or the other way round) |
| 404 | not_found (unknown respondent, round or question) / welcome_survey_not_deployed |
| 409 | cycle_not_open_or_item_not_in_set / no_open_cycle / conflict / welcome_section_unavailable (with a human-readable detail) |
| 422 | value_outside_scale / constraint_violation / too_many_rows / url_and_events_required / invalid_section / invalid_sections / invalid_answers / wording_version_required |
| 429 | rate_limited / daily_limit_reached / sandbox_quota_exhausted |
Rate limits: sandbox signup 3 per 10 minutes and 5 per day per IP;
token issuance 30 per minute per client and 120 per minute per IP (cache the
one-hour token); sandbox write quota 2,000 per client: each embed-token request and
each answer sent through POST /respondents/{id}/answers counts
one; answers given inside the hosted check-in do not count. Limits are database-backed and fail
closed.
Idempotency: there is no idempotency header because the engine is
idempotent where it matters. Re-submitting an answer returns
{ "status": "duplicate" } with unchanged counts, roster sync is
an upsert, and launching an already-open round returns
already_launched. Retry any 5xx safely.
Certification: the gate for production keys
Before production credentials are issued, your integration passes an automated certification check run against your real pages. It checks the following.
- Result displays use the certification markup contract, so the honesty
metadata cannot be stripped client-side:
<div data-iwe-aggregate data-iwe-metric="WELLBEING_INDEX" data-iwe-method="design" data-iwe-suppressed="false"> <span data-iwe-value>71</span> <span data-iwe-ci>63 to 79</span> </div> <!-- provisional rows add: --> <span data-iwe-provisional>Provisional: small group</span> <!-- suppressed rows have data-iwe-suppression-reason, a data-iwe-withheld element, and NO data-iwe-value element at all --> - Suppressed rows render as withheld with the reason: never zero, never blank, no value anywhere in the page.
- Confidence intervals are shown beside values; provisional rows carry the small-group badge.
- Optionally, your webhook receiver is probed for correct signature verification.
The published dashboard example ships with a passing certification report and doubles as the reference implementation for both surfaces on one page.
Launch checklist
Employee side:
- ☐ Server-side: store
client_idandclient_secretin your secrets manager; exchange for a bearer token; cache for about 55 minutes. - ☐ Sync your roster with stable opaque
partner_user_ids (production) or use the synthetic roster (sandbox). - ☐ On your wellbeing surface, call
GET /respondents/ext:<id>/checkinto decide whether to show the entry point. - ☐ On click, request an embed token server-side and open the embed URL in a plain iframe; never expose the client secret to the browser.
- ☐ Handle
completedanddismissedevents; validate the message origin andsource === "iwe-embed"; leave the thank-you screen open until the person closes it. - ☐ If you drive your own survey UI, check
GET /embed/welcomebefore the regular check-in and respectneeded; the IWE-hosted page does this for you. - ☐ If you send nudges, gate them on
reminder-eligibilityand record them vianudges:record. - ☐ Subscribe to
cycle.openedto surface prompts without polling.
Employer side:
- ☐ Pull
GET /aggregatesper unit; joinGET /cyclesfor round numbers and dates; build trends fromWELLBEING_INDEXrows across rounds. - ☐ Render every row honestly: interval beside value, provisional badge when
method = "indicative", withheld-with-reason whensuppressed, and no value element in the page for suppressed rows. - ☐ Wire
aggregates.readyto refresh dashboards; verify webhook signatures with the 300-second replay window. - ☐ Use
GET /aggregates/exportfor anything leaving your product. It is modelled on the OWHS aggregate report and does not yet validate against the published OWHS schemas; see the open export. - ☐ Never build UI that promises team-level slicing, individual visibility, or screener results. The engine will not supply them, and the licence depends on not implying otherwise.
- ☐ Run certification-lite and keep the report; production keys are gated on it.