Want to integrate? API keys are issued by our team. Email team@selfandmatch.com to set up a partner account.
Self and Match Partner API (v1)
For partner platforms (resellers) that set up schools and clinics on Self and Match, unlock learners, and read results back. Base URL:
- Production:
https://selfandmatchapp.com/api/partner/v1(live keys only) - Sandbox:
https://staging.selfandmatchapp.com/api/partner/v1(test keys only)
This contract is separate from /api/v1 (the mobile app's API). We add fields
and endpoints without notice; breaking changes only come in /v2, with 90
days' notice.
Quick start
- We create your partner account and an API key in our admin. Keys look like
sam_live_…/sam_test_…and are shown once. Keep them on your server. - Check the key:
GET /me. - Create a customer:
POST /orgswith the organization and its first teacher (the owner). The teacher gets a "set your password" email. - Add learners:
POST /orgs/{orgId}/learners. We return a 4-digit PIN for any learner you didn't give one; store or print it, it isn't shown again. - Unlock the learners the teacher picked:
POST /orgs/{orgId}/learners/{learnerId}/unlock. Each unlock is one line on your monthly statement. - Kids sign in at the
loginUrlon each learner (org code + username pre-filled, they type their PIN), on selfandmatchapp.com or the app. Print it as a QR login card withGET /orgs/{orgId}/learners/{learnerId}/login-card. - Read results with
GET /orgs/{orgId}/submissions,…/learners/{id}/progressand…/rewards/earnings, or subscribe to webhooks.
Conventions
- Auth:
Authorization: Bearer <key>. Each key has scopes (below). - JSON in and out, camelCase fields, ISO 8601 UTC dates.
- Your IDs: send
externalIdon orgs, teachers, learners and groups, then filter lists with?externalId=. Unique per organization. - Org scope: everything under
/orgs/{orgId}only works for orgs linked to you. Any id that isn't yours, in any path, returns404 not_found, exactly like an id that doesn't exist. - Errors:
{ "error": { "code", "message", "details"?, "requestId" } }. 401 bad key, 403 missing scope or suspended account, 404 not found or not yours, 409 conflict, 422 validation, 429 rate limited. - Request-Id header on every response. Quote it when you contact us.
- Idempotency-Key header on any POST: a retry with the same key and body
returns the first response (header
Idempotent-Replayed: true); the same key with a different body returns422 idempotency_key_reused. Keys last 24h. - Pagination:
?limit=(1–100, default 50) and?cursor=. Lists return{ data, hasMore, nextCursor }; passnextCursorascursor. - Rate limits: 300 reads and 60 writes per minute per key, plus a per-IP cap
on bad keys. Bulk calls (creating learners as a
{ "learners": [...] }list) also count against a separate 10-per-minute bulk limit per key.X-RateLimit-*headers show where you are (X-RateLimit-Bulk-*on bulk calls); 429 includesRetry-After. A 429 is never stored against your Idempotency-Key, so retry with the same key. - Acting user (optional):
SAM-Acting-User: <userId or teacher externalId>records which teacher an action was for (audit log, "assigned by"). It doesn't change permissions.
Scopes
| Scope | Allows |
|---|---|
orgs:read / orgs:write | list/read orgs; create/update orgs |
users:read / users:write | list/read teachers; add, update, remove, invite |
learners:read / learners:write | list/read learners (no names); create, update, archive, reset PIN |
learners.pii:read | learner names, email, date of birth, gender in responses (and the name on login cards) |
learners:delete | permanently delete learners (with learners:write) |
groups:read / groups:write | groups and their members |
licenses:read / licenses:write | licence list; unlock and lock learners |
results:read | forms, submissions, progress, goals, reward earnings |
forms:write | create, edit, publish, pause, resume, archive, duplicate, delete and send forms; the question bank |
submissions:write | the teacher's side of a check-in: match, mark absent, reopen, manual entry |
embed | one-time codes for the embedded teacher screens |
events:read | the event log |
Endpoints
Account
GET /me: partner, key mode and scopes.
Organizations
GET /orgs(?externalId=)POST /orgs:{ name, type?, timezone?, country?, street?, city?, state?, zipCode?, externalId?, owner: { email, firstName, lastName, externalId?, orgWideAccess?, sendInvite? } }. The owner must be a new email; an existing Self and Match account returns409 email_in_use: link it with a Connect request instead (below).GET /orgs/{orgId}: includeslearnerCount,teacherCount,learnerLoginUrl.PATCH /orgs/{orgId}
Connecting an existing account
For a school that already uses Self and Match. The org owner approves on our own page, in a popup (never an iframe), so they only ever type their password on selfandmatchapp.com. Nothing in the org is moved or deleted.
POST /orgs/connect-requests(orgs:write):{ externalId?, returnOrigin? }→201 { id, status: "pending", url, expiresAt }. Openurlwithwindow.open(). It's single use and lasts 30 minutes.returnOriginmust be one of your allowed embed origins.- The owner signs in, picks the org and approves. We link it (
linkType: "connected"), send theorg.connectedwebhook, and post{ type: "sam:org.connected", data: { requestId, orgId } }towindow.openeratreturnOrigin(orsam:connect.declined). GET /orgs/connect-requests/{id}(orgs:read):statusispending,approved(withorgId),declinedorexpired. Poll this if the popup message doesn't arrive.
Unlinking
POST /orgs/{orgId}/unlink(orgs:write): you stop managing the org. Nothing in it is moved or deleted, and its teachers keep signing in to Self and Match. If your sign-in was managed, teachers who never signed in here get a "set your password" email. Sendsorg.unlinked. Afterwards every call for that org returns404; to manage it again, the owner approves a new Connect request.
Teachers
GET /orgs/{orgId}/users(?externalId=&role=)POST /orgs/{orgId}/users:{ email, firstName, lastName, role?, orgWideAccess?, groupIds?, externalId?, sendInvite? }. Roles:ORG_OWNER,MANAGER,IMPLEMENTOR(default),OBSERVER.GET | PATCH | DELETE /orgs/{orgId}/users/{userId}: DELETE removes them from this org only. The lastORG_OWNERcan't be removed or demoted (409 last_owner).POST /orgs/{orgId}/users/{userId}/invite: resend the set-password email (7 days).
Learners
-
GET /orgs/{orgId}/learners:?status=ACTIVE|INACTIVE|ARCHIVED&groupId=&externalId=&username=&license=active|none&updatedSince= -
POST /orgs/{orgId}/learners: one learner, or{ "learners": [ … ] }(max 100, all or nothing). Fields:firstName, lastName, username, pin?, externalId?, email?, dateOfBirth?, gender?, language?, readAloud?, groupIds?. -
GET | PATCH | DELETE /orgs/{orgId}/learners/{learnerId}: DELETE archives; data and licence history are kept. -
POST /orgs/{orgId}/learners/{learnerId}/reset-pin:{ pin? } -
GET /orgs/{orgId}/learners/{learnerId}/progress:?formId=&from=&to= -
GET /orgs/{orgId}/learners/{learnerId}/goals(results:read): what the learner is working on, read-only.?includeArchived=trueadds the earlier ones. Newest first:{ learnerId, learnerExternalId, goals: [{ id, text, formId, source, editedFromSuggestion, active, createdAt, archivedAt }] }.sourceisWRITTENorSUGGESTED;formIdis set when the goal came from one form. A learner with no goal gets an emptygoalslist. Writing goals through the API isn't available yet. -
GET /orgs/{orgId}/learners/{learnerId}/login-card: a printable PDF card with a QR code of the login link, the org code, username and link (never the PIN). The learner's name is the title withlearners.pii:read; otherwise the username.?format=jsonreturns{ learnerId, username, orgCode, loginUrl, qrSvg, name? }instead, to print your own. -
POST /orgs/{orgId}/learners/{learnerId}/permanent-delete(learners:write+learners:delete): deletes the learner and all their data for good. Two calls:- Empty body →
{ confirmToken, expiresAt, willDelete: { learner, submissions, rewardEarnings }, willKeep }. Nothing is deleted yet. The token works for 10 minutes, for this learner only. { "confirmToken": "…" }→{ id, deleted: true }and thelearner.deletedwebhook. A wrong or expired token is422 invalid_confirm_token.
Works on active and archived learners. Removed: the learner, their check-ins and answers, reward earnings, group and form assignments, devices and sign-ins. Kept, without the learner: licence rows (a seat the org bought goes back to its pool; a seat you bought is revoked) and your statement lines.
- Empty body →
Each learner has license: { state: active|expired|locked|none, licenseId, validUntil }
and loginUrl.
Unlocking (licences)
POST /orgs/{orgId}/learners/{learnerId}/unlock: buys one seat at your price and puts it on the learner.- No active licence → charged, 12 months from now.
201,outcome: "unlocked". - Active, in its last 30 days → charged, 12 months added to the current end date.
200,outcome: "renewed". - Active with more than 30 days left → no charge.
200,outcome: "already_active". - Safe to retry: two calls at once never charge twice.
- There's no auto-renew: renew by calling unlock again.
- No active licence → charged, 12 months from now.
POST /orgs/{orgId}/learners/{learnerId}/lock: stops access now. No refund; a later unlock is a new charge.GET /orgs/{orgId}/licenses:?expiringWithin=30d&status=active|expired|locked&learnerId=(withexpiringWithin, soonest first: use it to renew in bulk).
When a licence expires the learner sees "inactive" and all their data is kept.
Groups
GET | POST /orgs/{orgId}/groups: POST{ name, description?, color?, externalId?, learnerIds?, userIds? }GET | PATCH | DELETE /orgs/{orgId}/groups/{groupId}: GET includeslearnerIds,userIds.POST | DELETE /orgs/{orgId}/groups/{groupId}/learners:{ learnerIds }POST | DELETE /orgs/{orgId}/groups/{groupId}/users:{ userIds }
Forms
The same form the form builder makes, in one body. Building forms by hand is easiest in the embedded builder (below); these endpoints are for syncing.
POST /orgs/{orgId}/forms(forms:write): saved asDRAFT, orACTIVEwith"publish": true.
3 to 5 questions. A manual (send-now only) form uses{ "title": "Morning check-in", "viewType": "ALL_QUESTIONS", "buttonStyle": "EMOJI", "questions": [{ "text": "Did I listen?" }, { "questionId": "…" , "text": "…" }, { "text": "…", "isAttainable": true }], "schedules": [{ "type": "SCHEDULED", "scheduleMode": "PATTERN", "startTime": "09:00", "endTime": "15:00", "intervalFrequencyMinutes": 60, "daysOfWeek": [1,2,3,4,5], "effectiveFrom": "2026-10-01" }], "groupIds": ["…"], "learnerIds": ["…"], "goalPercentage": 80, "yesYesPoints": 2, "noNoPoints": 1, "mismatchPoints": 0, "reward": { "name": "Extra recess", "frequency": "DAILY" }, "externalId": "your-id", "publish": false }"schedules": [{ "type": "MANUAL", "effectiveFrom": "…" }]. Every group, learner and question id must be in this org (422with the bad ids indetails). Publishing needs every assigned learner unlocked:409 unlicensed_learnerswith their ids.GET /orgs/{orgId}/forms/{formId}(results:read): questions (id= the form's question,questionId= the reusable question), schedules, reward,groupIds,learnerIds.PATCH /orgs/{orgId}/forms/{formId}: only the fields you send change. Sendingquestions,schedules,groupIdsorlearnerIdsreplaces that whole list.DELETE /orgs/{orgId}/forms/{formId}: soft delete; results are kept.POST /orgs/{orgId}/forms/{formId}/publish(DRAFT → ACTIVE),/pause(ACTIVE → PAUSED),/resume(PAUSED → ACTIVE),/archive(any → ARCHIVED). Anything else is409 invalid_status.POST /orgs/{orgId}/forms/{formId}/duplicate: a new DRAFT copy (201).POST /orgs/{orgId}/forms/{formId}/send-now:{ durationMinutes? }(5–480). Opens a check-in now for an ACTIVE form.
Send SAM-Acting-User to record which teacher made the change; otherwise it's
recorded as the org's owner.
Question bank (forms:write): reusable questions to send as questionId.
GET /question-bank:?search=. The built-in questions.GET /orgs/{orgId}/question-bank:?search=&source=built_in|organization. Built-in plus the org's own.
Each question: { id, text, reminder, source: "built_in"|"organization", hasMedia, createdAt }.
The teacher's side of a check-in
For matching in your own UI (submissions:write). Each returns the submission
as GET /submissions/{id} shows it afterwards. A check-in in the wrong state is
409 invalid_status (with its status). Send SAM-Acting-User to record
the teacher as "matched by" / "reopened by".
POST /orgs/{orgId}/submissions/{submissionId}/match:{ answers: [{ formQuestionId, answer }] }. For aSUBMITTEDcheck-in; one answer per question the learner answered. AddsmetGoal.POST …/mark-absent: excused absence for aPENDINGorMISSEDcheck-in. AddspreviousStatus.POST …/reopen: gives aMISSEDorABSENTcheck-in another 24 hours (back toPENDING). AddsreopenCount.POST …/manual-entry:{ answers: [{ formQuestionId, learnerAnswer, teacherAnswer }] }for every question on the form. For aPENDINGorMISSEDcheck-in; scored andMATCHEDat once (manualEntry: true). AddsmetGoal.
Answers that skip a question, repeat one, or name one that isn't on the check-in
are 422 with the ids in details.
Results
GET /orgs/{orgId}/forms:?status=(questions and assignments included)GET /orgs/{orgId}/submissions:?formId=&learnerId=&groupId=&status=MATCHED,ABSENT&from=&to=&updatedSince=GET /orgs/{orgId}/submissions/{submissionId}GET /orgs/{orgId}/rewards/earnings:?learnerId=&formId=&earned=true|false&since=(points earned vs target, as the app shows)
Statuses: PENDING, PAUSED, SUBMITTED, MATCHED, MISSED, EXPIRED, ABSENT.
ABSENT is an excused absence and is left out of rates, as in the app.
Events
GET /events:?since=&type=&orgId=, oldest first.
Embedding our screens
Show our real form builder, matching and results screens inside your page, as a signed-in teacher, without them signing in again. Kids' screens are never embedded.
- Your server asks for a one-time code (
embedscope):POST /embed-sessions{ orgId, userId | externalUserId, page?, returnOrigin }→201 { code, expiresAt, page, scriptUrl }. The code works once, within 60 seconds.returnOriginis your page's origin, exactly as listed in your allowed embed origins (https://app.example.com, no path or trailing slash).page:forms(default),forms/new,forms/{formId},forms/{formId}/schedule,forms/{formId}/assign,matching,analytics. - Your page loads our script and mounts the frame:
The code is passed to the frame by<div id="sam"></div> <script src="https://selfandmatchapp.com/embed.js"></script> <script> const embed = SelfAndMatch.mount('#sam', { code, page: 'forms' }); embed.on('form.published', (d) => console.log('published', d.formId)); embed.on('session.expired', () => { /* get a new code and mount again */ }); embed.on('close', () => embed.destroy()); </script>postMessage, never in a URL. Don't put it in a URL or log it yourself either. - The teacher works in the frame for up to 2 hours (then
session.expired). The frame follows its content height (resize); passautoResize: falseto size it yourself.embed.navigate('matching')switches screens.
Events (embed.on(type, fn) or onEvent(type, data)), only ever sent to your exact origin:
| Event | Data |
|---|---|
ready | { page } |
resize | { height } |
navigated | { page } |
form.published | { formId } |
match.completed | { submissionIds } |
session.expired | |
close | the teacher pressed close |
error | { message } (e.g. expired code) |
Screens outside the embed (billing, team, settings) aren't reachable from the frame, and the embedded session can't reach them either. Look: we can hide our header bar and use your accent colour; ask us to set this on your account.
Browsers: the session uses a partitioned cookie, so it works in Chrome with
third-party cookies blocked. Where the browser refuses the cookie (older
Safari), the frame keeps the session in memory instead; a full reload of the
frame then ends it (session.expired), and you mount again with a new code.
Managed sign-in
If you sign your teachers in yourselves, we can set your account to managed
with your login URL. Then, while an org is linked to you, our sign-in and
forgot-password screens tell its teachers "Your account is managed by <you>"
with a button to your login page. Nothing is blocked: they can still sign in
here. The apps can ask POST /api/v1/auth/managed-sign-in { email } →
{ managed, partnerName?, loginUrl? }. If an org is unlinked, teachers who
never signed in here directly get a normal "set your password" email.
Webhooks
We POST JSON to your HTTPS endpoint(s):
{ "id": "cm4x…", "type": "license.expiring", "createdAt": "…", "data": { "organizationId": "…", "learnerId": "…", "learnerExternalId": "…", "validUntil": "…", "daysLeft": 7, "warning": "7d" } }
Events: org.created, org.connected, org.unlinked, form.published, learner.created, learner.archived,
learner.deleted, license.unlocked, license.renewed, license.locked,
license.expiring (30 and 7 days before), license.expired,
submission.submitted, submission.matched, submission.missed, submission.absent, submission.reopened.
Payloads carry IDs, externalIds, statuses, scores and times only: never names or answers. Fetch details with your key.
Verify every delivery. Header
SAM-Signature: t=<unix seconds>,v1=<hex>, where
v1 = HMAC-SHA256(secret, "<t>.<raw body>"). Compare in constant time and
reject anything older than 5 minutes. Also sent: SAM-Event-Id, SAM-Event-Type.
Reply 2xx within 10 seconds. We retry failures at 1, 5, 15, 60 minutes, then 3,
6 and 12 hours (about a day), then stop and email you. Events can arrive more
than once or out of order: de-duplicate on id. Use GET /events?since= to
catch up.
Billing
Each unlock or renewal is one line on a monthly statement. Statements go out on
the 1st through Stripe (email with a pay page and PDF), or as a manual invoice
if agreed. The partner portal (/partner when signed in) shows statements,
this month's unlocks, and learners expiring soon.
Whether your orgs may also buy seats themselves on our billing page is part of your agreement. If not, their card checkout, invoice requests and licence codes are turned off and the billing page says seats come from you. That stays so while the org is linked to you, including while your account is suspended.