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, 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

In short: 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 identically but are provisioned by the IWE team for your own deployment.

Environments and base URLs

SandboxProduction (per licensee)
API basehttps://sandbox.wellbeingengine.iohttps://{licensee}.api.iwe.alltoogether.com
Embed originhttps://sandbox.embed.iwe.alltoogether.comhttps://{licensee}.embed.iwe.alltoogether.com
CredentialsSelf-serve, instantProvisioned by the IWE team; secret delivered once over a secure channel
Scopescheckin:write, aggregates:read, usage:readAll four, including respondents:write
WorkforceFour synthetic units: syn-normal, syn-tiny, syn-sleep, syn-lowcompYour real roster, synced by you
Lifetime / quota30 days, 2,000 answer writesContractual
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.

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.

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, webhook registration
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.

Part 2. Your people: the employee side

In short: 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, 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:

  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.embed.iwe.alltoogether.com/checkin?token=...",
      "expires_at": "<now + 10 minutes>" }
    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. A 404 means no such respondent exists in the engine (not yet synced).
  2. 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:
    const 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,
    });
    Raw-iframe equivalent: set iframe.src to the embed_url with referrerpolicy="no-referrer", and listen for postMessage events, 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.
  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" } ]

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:

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

In short: 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": "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, ... } ]
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 actually asked.
PARTICIPATIONResponse rate as its own metric, kept separate from the wellbeing score so engagement and outcome never get conflated.
BENEFITS_REACHOf 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

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. 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.
  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": "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 receivesEnforced by
Any individual's answersNo 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 peopleCHECK 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 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

In short: 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 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>" }
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 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:

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. Per-licensee production deployments pin their own base URLs, scopes and frame-ancestors policy at provisioning.