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, not by application code. 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 is a four-part journey. Follow it 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 | https://{licensee}.api.iwe.alltoogether.com |
| Embed origin | https://sandbox.embed.iwe.alltoogether.com | https://{licensee}.embed.iwe.alltoogether.com |
| Credentials | Self-serve, instant | Provisioned by the IWE team; secret delivered once over a secure channel |
| Scopes | checkin:write, aggregates:read, usage:read | All four, including respondents:write |
| Workforce | Four synthetic units: syn-normal, syn-tiny, syn-sleep, syn-lowcomp | Your real roster, synced by you |
| Lifetime / quota | 30 days, 2,000 answer writes | 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.
Get your keys
Sandbox credentials are self-serve. One POST, no contract, no humans:
POST /sandbox/signups
Content-Type: application/json
{ "email": "you@example.com", "product_name": "Acme HR" } // both optional
// 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"]
}
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: you
cannot create or erase respondents in the sandbox, only work with the
synthetic roster (respondent IDs follow the pattern syn-normal-01
through syn-normal-12).
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, webhook registration |
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.
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, not a missing feature.
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. The
consequence to design for: 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-44", "tenure_band": "2-5y", "work_pattern": "shift" },
"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 do exactly one thing: 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.embed.iwe.alltoogether.com/checkin?token=...", "expires_at": "<now + 10 minutes>" }404means no such respondent exists in the engine (not yet synced). - Your page opens the embed URL, with the SDK or a raw iframe. The
npm package (
npm install @alltoogether/iwe-embed) is a zero-dependency iframe helper:
Raw-iframe equivalent: setconst handle = IWE.mount('#wellbeing', { token: tok.token, // fetched from YOUR backend baseUrl: 'https://sandbox.embed.iwe.alltoogether.com', onComplete: () => { showThanks(); handle.unmount(); }, onDismiss: () => { handle.unmount(); }, onError: (e) => console.warn(e.code, e.message), minHeight: 520, });iframe.srcto theembed_urlwithreferrerpolicy="no-referrer", and listen forpostMessageevents, accepting only messages whose origin is the embed origin and whose payload is{ source: "iwe-embed", type: "completed" | "dismissed" }. 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 rather than failing silently.
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" } ]
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 hold structurally rather than by policy:
| 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": "acme-ltd",
"metric": "WELLBEING_INDEX",
"question_code": null,
"n": 11, "headcount": 12, "completion_rate": 0.92,
"suppressed": false, "suppression_reason": null,
"value": 71.0, "ci_low": 63.4, "ci_high": 78.6, "sd": 11.2,
"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, ... } ]
| 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 actually asked. |
PARTICIPATION | Response rate as its own metric, kept separate from the wellbeing score so engagement and outcome never get conflated. |
BENEFITS_REACH | Of people with an elevated signal this round, the share who accepted a route to support. Safeguarding routes are excluded from both sides of the fraction. |
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. - Refuse, don't hide. 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": "x-iwe:work-ability",
"instrument_citation": "<citation anchor - 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). The JSON Schema enforces the same honesty rules as the database: a suppressed row with a value, or a shown row with n < 5, fails validation. Instruments appear as citations, never as question text. A published Python example pulls the export, validates it against the schema, and re-checks the rules in plain English.
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, not a policy document:
| Never receives | Enforced by |
|---|---|
| Any individual's answers | No read path exists. Individual tables are locked deny-by-default, gateway-only, and the gateway's own database role has no SELECT on raw responses. The audit log proves no individual-level query ran. |
| Any figure built on fewer than 5 people | CHECK constraint on the results table; contractual, and weakening it terminates the licence. The export schema rejects it independently. |
| 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 actually 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) and the benefits-awareness question mean. Individual routing events are never exposed.
Part 4. Go live
Webhooks
POST /webhooks
Authorization: Bearer <access token> // scope aggregates:read
{ "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 answer quota 2,000 writes per client, with answers and embed-token requests both counting. 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 (@alltoogether/iwe-cert-lite) run against
your real pages. The substance of it:
- 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 an iframe (SDK or raw); never expose the client secret to the browser.
- ☐ Handle
completedanddismissedevents; validate the message origin andsource === "iwe-embed". - ☐ 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; validate against the OWHS schema. - ☐ 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.