Champ Integration
Admissions API contract 2026-09-13.v1
This project is the receiver (Project B). Every request carries X-Champ-Contract-Version: 2026-09-13.v1; any other value returns 409 unsupported_contract_version.
Endpoints
GET /api/public/admissions/v1/health— no authGET /api/public/admissions/v1/admissions-info?organisation_ref=demo-academy— connector keyPOST /api/public/admissions/v1/enquiries— connector key (+ parent session when signed in)GET /api/public/admissions/v1/parent/enquiries— connector key + parent session
Layer 1 — connector key
Generated by an owner in Staff → Settings and shown once. Format cak_<uuid>.<secret>. Only SHA-256(secret) is stored. On each request the row is found by the uuid and the hash is compared timing-safely; the key must be active and the organisation_ref must be on its allow-list, otherwise 403 forbidden_organisation. A connector key never unlocks parent records.
Project A configuration (names only): ADMISSIONS_API_BASE_URL, ADMISSIONS_CONNECTOR_KEY.
Layer 2 — parent session hand-off
- Project A opens a popup to
{ADMISSIONS_API_BASE_URL}/parent/sign-in?origin=<Project A origin>&state=<one-time value>. The origin is not a secret; it only says where the result may be delivered.stateis optional, opaque and generated by Project A per attempt (1–256 chars,A-Z a-z 0-9 . _ ~ -); it is never interpreted here. - The parent signs in here with their own email and password. Project A never sees the password.
- Project B checks the requested origin against the organisation's approved hand-off list (owner-managed). If it is not approved, no session is issued to that page.
- Project B mints
cps_<uuid>.<secret>, stores SHA-256(secret) with the parent id, organisation id and an expiry 15 minutes ahead, and delivers it withwindow.opener.postMessage(message, approvedOrigin)— the exact approved origin, never*, never in a URL, query string or chat message. Thestatevalue is echoed back unchanged. The popup then closes.
// Message received by Project A (browser)
{
"type": "champ.parent_session",
"contract_version": "2026-09-13.v1",
"token": "cps_<uuid>.<secret>",
"expires_at": "2026-09-13T08:35:00.000Z",
"parent_display_name": "Naledi Moeng",
"state": "<the same value Project A sent>" // present only when ?state= was supplied
}What Project A must do with it
window.addEventListener("message", (e) => {
if (e.origin !== ADMISSIONS_ORIGIN) return; // only accept from Project B
if (e.data?.type !== "champ.parent_session") return;
if (e.data.contract_version !== "2026-09-13.v1") return;
if (e.data.state !== expectedState) return; // must match the one-time value you generated
// Consume expectedState so it cannot be replayed.
// Send the token to Project A's own server over HTTPS and keep it there,
// bound to the visitor's chat session (e.g. server-side store or HttpOnly cookie).
// Never print it in the chat, never put it in a URL, never store it in the transcript.
});
// Server-to-server call from Project A's backend:
fetch(ADMISSIONS_API_BASE_URL + "/api/public/admissions/v1/parent/enquiries", {
headers: {
"X-Champ-Connector-Key": ADMISSIONS_CONNECTOR_KEY,
"X-Champ-Contract-Version": "2026-09-13.v1",
"X-Champ-Parent-Authorization": token,
},
});On 401 parent_session_invalid (expired, tampered, or revoked) Project A should prompt the parent to sign in again. Never ask a parent to type or paste a token into the chat.
How Project B verifies a parent session
- Header missing or blank → 401
parent_authentication_required. - Token parsed as
cps_<uuid>.<secret>; malformed → 401parent_session_invalid. - Row looked up by uuid; SHA-256(secret) compared timing-safely with the stored hash.
- Expired or revoked → 401
parent_session_invalid. - The parent id and organisation come from the stored row only. Query parameters, body fields and any supplied parent or organisation id are ignored.
- Records are filtered by
parent_account_id = session.user_idand the session's organisation; the connector's allow-list must include that organisation.
Submitting while signed in
Send X-Champ-Parent-Authorization together with the connector key and set parent_session_present: true. The flag is only a declaration:
parent_session_present: truewithout the header → 401parent_authentication_required.- Header present but
parent_session_present: false→ 400invalid_request. - Header present but invalid/expired → 401
parent_session_invalid(the enquiry is not saved). - Session belongs to a different organisation than
organisation_ref→ 403forbidden_organisation. - Valid → the enquiry is linked server-side and the response has
linked_to_parent_account: true.
Idempotency and conflicts
- First save → 201,
duplicate: false. - Identical replay of the same
submission_id→ 200,duplicate: true, original reference, no second record. - Same
submission_idwith different enquiry data (name, contact, grade, year, message or organisation) → 409submission_conflict. Project A must generate a newsubmission_idfor changed data. Idempotency-Key, when sent, must equalsubmission_id(400 otherwise).saved: trueis only returned after the row is committed.
Contract additions in this implementation
- New error code
submission_conflict(HTTP 409) — reusedsubmission_idwith different data. - The hand-off message shape above (
champ.parent_session) and the?origin=query parameter on/parent/sign-in. - The strict
parent_session_present/ header consistency rules listed under "Submitting while signed in".
Account linking for guest enquiries
A guest enquiry is never linked because an email or phone matches. Staff verify the parent using the contact details on the enquiry, issue a one-time claim code (shown once, stored hashed, valid 24 hours, 5 attempts), and the signed-in parent enters reference + code in the parent portal. Only then is parent_account_id set.