Early access. The sandbox is open now; production keys open at launch. How going live works
For partners and licensees

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

Sign up for sandbox keys with one API call, swap them for a one-hour access token, and you can make every call in this guide against a synthetic workforce. Production keys work the same way. They will be issued automatically once your integration is certified and you have accepted the terms and set up payment on the go-live page. Each production licensee will have its own database.

Environments and base URLs

SandboxProduction (per licensee)
API basehttps://sandbox.wellbeingengine.ioGiven to you at go-live
Embed originThe origin of the embed_url the API returnsThe origin of the embed_url the API returns
CredentialsSelf-serve, instantIssued automatically at go-live; the secret is shown once
Scopescheckin:write, aggregates:read, usage:readAll five, including respondents:write and webhooks:manage
WorkforceFour synthetic units: syn-normal, syn-tiny, syn-sleep, syn-lowcompYour real roster, synced by you
Lifetime / quota30 days, 2,000 writes (embed-token requests and answers sent through the API)Contractual
Iframe policyNo frame-ancestors restrictionframe-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.

ScopeGrants
respondents:writeRoster sync, erasure, launching survey rounds
checkin:writeReading a person's check-in state, submitting answers, requesting embed tokens, reminder eligibility
aggregates:readRounds list, results feed, OWHS export
webhooks:manageRegistering webhooks (production keys only; sandbox keys do not carry it)
usage:readMonthly enrolled-respondent count (the billing metric)

Prove it works in five minutes

  1. Sign up: POST https://sandbox.wellbeingengine.io/sandbox/signups. Save the secret immediately.
  2. Get a token: POST /oauth/token with your client credentials.
  3. Check state: GET /respondents/ext:syn-normal-01/checkin. The syn-normal unit keeps an open round.
  4. Request an embed token: POST /embed/tokens with {"respondent_ref":{"partner_user_id":"syn-normal-01"}}, then load the returned embed_url in a browser and complete the three questions.
  5. Read results: GET /aggregates?unit_id=syn-normal. Then try syn-tiny to 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.

Integration prompt
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

You register each employer as a flat unit and each person as an opaque ID. Your backend requests a short-lived embed token when someone opens their check-in, your page shows the IWE-hosted survey in an iframe, and one API call per user tells you whether a check-in is due right now.

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:

  1. Your backend requests an embed token for the signed-in user.
    POST /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>" }
    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. A 404 means no such respondent exists in the engine (not yet synced).
  2. Your page opens the embed URL in an iframe. There is no package to install; a few lines of your own code do it:
    const 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
      }
    });
    Accept only messages whose origin is the embed origin and whose payload is { source: "iwe-embed", type: "completed" | "dismissed" }. Keep the frame open after the first completed. It arrives when the answers are saved and the thank-you screen appears, and that screen carries the crisis signposting. The check-in sends completed again when the person presses Close; remove the frame then, or on dismissed. 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.
  3. 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.
  4. 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).
Why there is no client-side API. The gateway sets no CORS headers on purpose. If you find yourself wanting to call /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.

Status. The three endpoints below are live, and the sandbox has the welcome survey enabled with section A, so you can build and test the full first-contact flow against it today. The endpoints degrade gracefully: a deployment whose welcome survey is not enabled answers { "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:

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:

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:

StoreHoldsWho 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

One endpoint returns every result the employer is allowed to see, with the anonymity rules already applied and machine-readable. Your dashboard's job is to render honestly: value plus confidence interval, a provisional badge on small groups, and "withheld, with the reason" where the engine refuses.

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.

MetricWhat it is
WELLBEING_INDEXThe 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_MEANOne 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.
PARTICIPATIONReserved for response rate as its own metric. It is not computed today; use n, headcount and completion_rate on each row.
BENEFITS_REACHDefined 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

The honesty rules (what "suppressed" means for your code)

The one-sentence version. Any figure that would rest on fewer than five people arrives with 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.
  1. The n ≥ 5 floor. suppression_reason: "below-floor". Applies to every metric, every question, every round.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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 receivesEnforced by
Any individual's answersNo 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 peopleCHECK 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 formNon-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 breakdownsUnits do not nest; the parent column was removed from the schema.
Who did and did not answerCounts 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 exportCitation anchors only; no export path reads the question text.
Invented interpretation bandsOnly 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:

Part 4. Go live

Subscribe to webhooks so you never poll, handle the small closed set of error codes, pass the automated certification, and work through the launch checklist.

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>" }
EventFires whenUse it to
cycle.openedA new round opens for a unitSurface "check-in due" prompts without polling
cycle.closedThe 14-day window endsStop prompting; expect results soon
aggregates.readySuppression-checked results are computedRefresh the employer dashboard or pull the export
respondent.erasedAn erasure completesReconcile your own records
deliverability.flagDelivery problems detectedInvestigate 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).

StatusCodes you will meet
400unsupported_grant_type
401invalid_client / missing_bearer_token / invalid_token / invalid_or_expired_embed_token / sandbox_expired
403insufficient_scope (with required) / wrong_token_type (an embed token used on a partner route, or the other way round)
404not_found (unknown respondent, round or question) / welcome_survey_not_deployed
409cycle_not_open_or_item_not_in_set / no_open_cycle / conflict / welcome_section_unavailable (with a human-readable detail)
422value_outside_scale / constraint_violation / too_many_rows / url_and_events_required / invalid_section / invalid_sections / invalid_answers / wording_version_required
429rate_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.

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:

Employer side:

The IWE (Intelligent Wellbeing Engine) by Alltoogether. Generated from the reference build on 12 Aug 2026; sandbox surface verified live the same day, including the welcome survey endpoints. Each production licensee will have its own database; base URLs, scopes and the frame-ancestors policy are set at provisioning.