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.
Multi-school: published school information (additive)
Every organisation now has versioned school information managed by its school administrator (draft → publish → history). The API returns only the currently published version; drafts and archived versions are never served. GET /api/public/admissions/v1/admissions-info keeps its original fields and gains the optional ones below; GET /api/public/admissions/v1/school-information is an alias with the identical payload. Both require a connector key permitted for the requested organisation_ref (403 forbidden_organisationotherwise) and return 403 forbidden_organisation for a suspended school.
{
"ok": true,
"organisation_ref": "demo-academy",
"school_name": "Demo Academy",
"logo_url": string | null,
"workspace_mode": "demonstration" | "live",
"is_demo": boolean,
"information_version": 2, // integer, increments on every publish
"published_at": "2026-09-13T17:51:35Z", // publication timestamp
"updated_at": "…", // unchanged legacy field (= published_at)
"requirements": [{ "id", "title", "detail" }], // legacy
"steps": [{ "id", "title", "detail" }], // legacy
"notes": string | null, // legacy
"programmes": [{ "id", "name", "level", "grades": string[], "description", "duration", "intake" }],
"fees": [{ "id", "programme", "grade", "fee_type", "currency", "amount", "period",
"payment_schedule", "effective_from", "notes", "status": "published" }],
"required_documents": [{ "id", "name", "detail", "mandatory": boolean }],
"deadlines": [{ "id", "title", "date", "detail" }],
"contacts": [{ "id", "label", "name", "phone", "email", "role" }],
"school_hours": [{ "id", "days", "open", "close", "notes" }],
"faqs": [{ "id", "question", "answer" }],
"announcements": [{ "id", "title", "body", "published_on", "expires_on" }]
}- A fee is identified by
programme + grade + fee_type + period. Editing one fee replaces only that entry; the editor refuses duplicates of the same identity. Other fees and other schools are untouched. - Cache: Project A may cache a school's information for at most 60 seconds (key it by
organisation_ref; treat a changedinformation_versionas invalidation). The maximum delay between "Publish update" here and the chatbot answering with the new value is therefore 60 seconds. There is no push invalidation in this version. - Parent sign-in hand-off accepts an optional
organisation_refquery parameter on/parent/sign-in(same pattern as the API); the popup echoes it asorganisation_refin thechamp.parent_sessionmessage. Sessions remain bound to the parent's own schools; a session used on a submission for a school the parent is not registered with is rejected with 403. - Each school has its own approved hand-off origins and its own connector keys. A key issued for one school cannot read, submit or list enquiries for another.
Manual pairing of a new school (no automation exists yet)
- Platform owner creates the school at
/staff/schools(name, logo, contacts, workspace mode). The organisation reference is generated once and never changes. - Copy the exact
organisation_reffrom the school page into the matching school record in Champ AI Solutions. - On the school page, issue a connector key scoped to that school and store it in the source project's secure settings (never in chat). Add the source website origin(s) to the school's approved hand-off origins.
- Grant an administrator: assign an existing signed-in account, or create an explicit invitation link and send it yourself. Nothing is emailed automatically and typing an address grants nothing.
- The administrator signs in at
/staff, edits/staff/school-information, previews and publishes version 1. - Test from the source project with the school's key (health, information, enquiry, parent sign-in) while the school is still marked Setup, then set it to Active and enable the demo in Champ AI Solutions. Suspended schools are refused everywhere (API, submissions, publishing); Setup is a visible status only.