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

  1. 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.
  2. Check the key: GET /me.
  3. Create a customer: POST /orgs with the organization and its first teacher (the owner). The teacher gets a "set your password" email.
  4. 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.
  5. Unlock the learners the teacher picked: POST /orgs/{orgId}/learners/{learnerId}/unlock. Each unlock is one line on your monthly statement.
  6. Kids sign in at the loginUrl on each learner (org code + username pre-filled, they type their PIN), on selfandmatchapp.com or the app. Print it as a QR login card with GET /orgs/{orgId}/learners/{learnerId}/login-card.
  7. Read results with GET /orgs/{orgId}/submissions, …/learners/{id}/progress and …/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 externalId on 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, returns 404 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 returns 422 idempotency_key_reused. Keys last 24h.
  • Pagination: ?limit= (1–100, default 50) and ?cursor=. Lists return { data, hasMore, nextCursor }; pass nextCursor as cursor.
  • 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 includes Retry-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

ScopeAllows
orgs:read / orgs:writelist/read orgs; create/update orgs
users:read / users:writelist/read teachers; add, update, remove, invite
learners:read / learners:writelist/read learners (no names); create, update, archive, reset PIN
learners.pii:readlearner names, email, date of birth, gender in responses (and the name on login cards)
learners:deletepermanently delete learners (with learners:write)
groups:read / groups:writegroups and their members
licenses:read / licenses:writelicence list; unlock and lock learners
results:readforms, submissions, progress, goals, reward earnings
forms:writecreate, edit, publish, pause, resume, archive, duplicate, delete and send forms; the question bank
submissions:writethe teacher's side of a check-in: match, mark absent, reopen, manual entry
embedone-time codes for the embedded teacher screens
events:readthe 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 returns 409 email_in_use: link it with a Connect request instead (below).
  • GET /orgs/{orgId}: includes learnerCount, 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 }. Open url with window.open(). It's single use and lasts 30 minutes. returnOrigin must be one of your allowed embed origins.
  • The owner signs in, picks the org and approves. We link it (linkType: "connected"), send the org.connected webhook, and post { type: "sam:org.connected", data: { requestId, orgId } } to window.opener at returnOrigin (or sam:connect.declined).
  • GET /orgs/connect-requests/{id} (orgs:read): status is pending, approved (with orgId), declined or expired. 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. Sends org.unlinked. Afterwards every call for that org returns 404; 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 last ORG_OWNER can'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=true adds the earlier ones. Newest first: { learnerId, learnerExternalId, goals: [{ id, text, formId, source, editedFromSuggestion, active, createdAt, archivedAt }] }. source is WRITTEN or SUGGESTED; formId is set when the goal came from one form. A learner with no goal gets an empty goals list. 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 with learners.pii:read; otherwise the username. ?format=json returns { 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:

    1. Empty body → { confirmToken, expiresAt, willDelete: { learner, submissions, rewardEarnings }, willKeep }. Nothing is deleted yet. The token works for 10 minutes, for this learner only.
    2. { "confirmToken": "…" } → { id, deleted: true } and the learner.deleted webhook. A wrong or expired token is 422 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.

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.
  • 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= (with expiringWithin, 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 includes learnerIds, 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 as DRAFT, or ACTIVE with "publish": true.
    {
      "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
    }
    
    3 to 5 questions. A manual (send-now only) form uses "schedules": [{ "type": "MANUAL", "effectiveFrom": "…" }]. Every group, learner and question id must be in this org (422 with the bad ids in details). Publishing needs every assigned learner unlocked: 409 unlicensed_learners with 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. Sending questions, schedules, groupIds or learnerIds replaces 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 is 409 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 a SUBMITTED check-in; one answer per question the learner answered. Adds metGoal.
  • POST …/mark-absent: excused absence for a PENDING or MISSED check-in. Adds previousStatus.
  • POST …/reopen: gives a MISSED or ABSENT check-in another 24 hours (back to PENDING). Adds reopenCount.
  • POST …/manual-entry: { answers: [{ formQuestionId, learnerAnswer, teacherAnswer }] } for every question on the form. For a PENDING or MISSED check-in; scored and MATCHED at once (manualEntry: true). Adds metGoal.

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.

  1. Your server asks for a one-time code (embed scope): POST /embed-sessions { orgId, userId | externalUserId, page?, returnOrigin } → 201 { code, expiresAt, page, scriptUrl }. The code works once, within 60 seconds. returnOrigin is 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.
  2. Your page loads our script and mounts the frame:
    <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>
    
    The code is passed to the frame by postMessage, never in a URL. Don't put it in a URL or log it yourself either.
  3. The teacher works in the frame for up to 2 hours (then session.expired). The frame follows its content height (resize); pass autoResize: false to size it yourself. embed.navigate('matching') switches screens.

Events (embed.on(type, fn) or onEvent(type, data)), only ever sent to your exact origin:

EventData
ready{ page }
resize{ height }
navigated{ page }
form.published{ formId }
match.completed{ submissionIds }
session.expired
closethe 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.