Create and manage learners¶
A learner is the core record in Cognassist: a person you enrol so they can take the cognitive assessment and receive support tailored to how they think and learn. This guide covers creating learners, keeping them reconciled with your own system of record, and the tutor-assignment model that decides who can see each learner.
All calls use the base URL https://api.uk.cognassist.com and a bearer token (see Getting started). Your credentials are scoped to your organisation, so every endpoint here only ever returns your organisation's learners.
The model in one paragraph¶
Store one stable id per learner on your side (clientReference) and create each learner once. A learner's email is unique within your organisation, so a second create for the same person is rejected, not duplicated; you still reconcile before creating so you handle that cleanly and keep your own id mapping. When you reconcile, page the learner list and match rows on email, the human key the list returns, and fetch a full record only when you need to confirm or repair the id you stored. Everything below is the detail on top of that.
For the join-key model (which identifier does which job, and why you match on email), see Core concepts. This guide uses those keys; it does not re-explain them.
Create a learner¶
POST /v1/learners creates a single learner and returns 201 Created with two Cognassist-side GUIDs, learnerId and learnerUserId. Store learnerId against your own record. It is the id you pass on every later call for that learner (invite, retrieve results, read evidence).
curl -X POST "https://api.uk.cognassist.com/v1/learners" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"clientReference": "12345ABCDE",
"primaryTutorEmail": "tutor.name@example.com"
}'
The example above is trimmed to the fields that carry the model. The create body has more required fields than that, plus several optional ones.
Full create body: required and optional fields
Required: firstName, lastName, email, primaryTutorEmail, gender, postcode, dateOfBirth.
Strongly recommended: clientReference, your own id for the learner and your durable join key. Always send it; without it you have no stable key of your own to reconcile against. (The API reference lists it among the required create fields.)
Optional: programmeLevel (an integer 1 to 7), learnerUniqueReference, mobileNumber, endDate, courseDetails, hasEhcp, provisionType, tags.
programmeLevel is optional, even though Getting started may show a leaner body that omits it. learnerUniqueReference is the optional slot for a ULN: it is validated and must be unique within your organisation when supplied, but it is optional, so it is not your primary join (see Core concepts).
gender is a string, not a number, and it must match one of the supported values exactly, including capitalisation and punctuation: Male, Female, NotDisclosed, Non-binary/Non-conforming, or Another. An unrecognised value is rejected with a 400 naming LearnerBaseDetails.Gender, and so is a numeric value. The reads return the same strings.
courseDetails is free text describing what the learner is studying, capped at 230 characters; a longer value is rejected with a 400. hasEhcp records whether the learner has an Education, Health and Care Plan and defaults to false when you omit it. Both are also returned on the reads and can be changed later with PUT /v1/learners/{learnerId}.
provisionType is the provision your system of record holds the learner under, sent as the integer id from the ProvisionType list: 30 for Apprenticeships (at all levels), for example. The name is not accepted, so a change to the wording can never break a write. Omit it and the learner is created at Not set; send null, a name, or an id outside the list and the create is rejected with a 400, and no learner is created (an unrecognised id is answered with every accepted id and its name). The reads return it as provisionType, provisionSource and provisionUpdatedAt: see what the provision fields mean.
tags is an optional list of tag names, applied after the learner is created rather than as part of it. Any tag your organisation does not already hold is created by the assignment, and the same rules apply as on the learner-scoped tag routes: 3 to 20 characters of letters, digits, : or -, no spaces, and at most 10 tags per learner.
That ordering is visible to you when something is wrong, so handle it deliberately: a tag name that breaks those rules returns a 400 naming the offending tag after the learner has already been created without tags. Recover with POST /v1/learners/{learnerId}/tags rather than by retrying the create, which would then be rejected for the duplicate email. See Tags.
A 403 is the opposite case, and the distinction is the point. Tagging is enabled per organisation, and whether yours has it is settled before the learner is created - so if you send tags and tagging is not enabled for you, you get a 403 and no learner is created. Retry without tags to create the learner untagged, or contact Cognassist to have tagging enabled and then send the create as it stands. The two failures are deliberately different: a 400 means the learner exists and only the tags did not apply; a 403 means nothing happened at all.
For exact types, formats, and validation rules, see the create endpoint in the API reference.
Two things about create trip integrations if you miss them, so internalise both before you write the loop.
Create does not update; an unresolved primary or secondary tutor rejects the whole request
POST /v1/learners creates; it does not update an existing learner. Both tutor references must resolve to a tutor who already exists: if primaryTutorEmail is missing or does not resolve, the create is rejected with a 400 (Invalid primary tutor.) and no learner is created; likewise, if any email in secondaryTutorEmails does not resolve to a known tutor, the entire request is rejected (400). Provision the tutors first.
A learner's email is unique within your organisation, so calling create again for the same person is rejected (User is already a Learner), not silently duplicated. Still look the learner up before every create and funnel all enrolment through a single check keyed on your clientReference, so two source systems handle an existing learner cleanly rather than tripping that error.
Provision your tutors up front, either with POST /v1/staff (see managing staff accounts) or in the Cognassist app, then map each learner to a tutor email your source system already knows. A learner's primaryTutorEmail must resolve to a tutor who already exists at the time of the create call, so the provisioning has to come first; otherwise the create is rejected with a 400 and no learner is created. Once the tutor account exists, retry the create, or use PUT /v1/learners/{learnerId} to move an existing learner to a different tutor. See Troubleshooting for diagnosing tutor assignment issues.
Batch SFTP as a limited fallback for getting learners in
To get learners in from a file-only source, a one-way-in SFTP upload is a limited fallback. Prefer POST /v1/learners wherever the source system can make an HTTPS call, so you get the learnerId back to react to and the reconcile step to avoid duplicates.
Reconcile against your system¶
Reconciling means: pull the learners Cognassist already holds, match them against your own records, then create only the ones that are missing (and PUT any that changed). The load-bearing detail is which field you match on, and that is decided by what the list returns.
GET /v1/learners returns a paged list with a top-level totalRecords. The list carries only email as a shared human key: it does not return clientReference and offers no server-side filter on it, so the reconcile loop matches on email, not clientReference. For the full list projection and why the match key is email, see Core concepts. clientReference stays your stable primary key on your own side; the point here is only that the API's list cannot be filtered or matched by it today.
When you have matched on email and need to confirm or repair the stored clientReference (an email changed, or you are backfilling a join you never captured), fetch the full record with GET /v1/learners/{learnerId}, which includes clientReference and learnerUniqueReference. Reserve this per-learner fetch for the rows that actually need it; running it for every row is roughly one call per learner, which is exactly the tight polling to avoid against an unquantified rate limit.
The loop reads totalRecords, pages the list, matches each row on email, then branches: PUT a changed match or POST a missing learner, stopping once it has collected totalRecords.
flowchart TD
A[Read totalRecords<br/>from the first page] --> B[Page GET /v1/learners]
B --> C[Match each row on email]
C --> D{Email in your<br/>source records?}
D -- "Found" --> E[PUT only if the record changed]
D -- "Not found" --> F[POST to create the learner]
E --> G{Collected<br/>totalRecords?}
F --> G
G -- "No" --> B
G -- "Yes" --> H[Done]
Paging and sizing the run
The list is paginated with pageSize, pageIndex, and includeOffProgrammeLearners. Because the response includes totalRecords, the stop condition is knowable without guessing the page count: stop once you have collected totalRecords items, or when a page returns fewer than pageSize rows.
pageIndex starts at 1. Sending 0, or leaving it out, returns a 400. totalRecords also lets you size the run up front: at a page size of N, expect about totalRecords / N list calls, far fewer than a per-row fetch.
The Power Automate worked example and Keep in sync both build on this loop; it is the canonical picture, so they link here rather than redraw it.
Update and delete¶
PUT /v1/learners/{learnerId} returns 204 No Content. Treat it as a replace, not a patch: build the request body from the learner's current full record, change the fields you mean to change, and send the whole thing. Read the learner first with GET /v1/learners/{learnerId} if you do not already hold their current values.
curl -X PUT "https://api.uk.cognassist.com/v1/learners/00000000-0000-0000-0000-000000000000" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"gender": "Female",
"postcode": "NE1 6BF",
"dateOfBirth": "2000-02-24",
"clientReference": "12345ABCDE",
"primaryTutorEmail": "new.tutor@example.com",
"endDate": "2030-06-30"
}'
firstName, lastName, email, gender, dateOfBirth and primaryTutorEmail are effectively required on update whatever the schema marks as optional: omitting any of them fails validation with a 400.
courseDetails, hasEhcp and provisionType are safe to omit
These three fields, and only these three, carry an explicit omit-to-keep guarantee: leave them out of the body and the stored values are preserved rather than cleared. That is what lets an integration written before they existed keep calling PUT without wiping them. To clear courseDetails deliberately, send it explicitly as null or ""; omitting it will not clear it. hasEhcp takes only true or false and returns a 400 if you send null. provisionType takes the integer id of a provision type, including 0 for Not set, and returns a 400 if you send null, a name, or an id outside the list (an unrecognised id is answered with every accepted id and its name). A value that changes the stored provision is recorded with provisionSource Api; re-sending the value already stored leaves the source and provisionUpdatedAt as they were. A PUT is applied as one change: if any field is rejected, provision included, nothing is stored. See what courseDetails and hasEhcp mean and what the provision fields mean.
DELETE /v1/learners/{learnerId} also returns 204. Reserve it for records created in error; use PUT to move a learner between tutors or programmes.
Tutor assignment governs visibility¶
Assignment is not just an attribute; it decides who in your organisation can see a learner and their results:
- Each learner has one primary tutor plus any number of secondary tutors.
- A tutor sees the learners assigned to them; a tutor manager sees their tutors' learners; an admin sees all learners. Leadership dashboards (Insights) are admin-only.
- Assigning a tutor requires a manager or admin.
- Primary versus secondary is a professional convention (who owns the plan and records evidence), not an app-enforced read-only lock.
Set the primary tutor at creation via primaryTutorEmail, or change it later with PUT. The tutor must already exist in Cognassist (see the create warning above).
Set secondary tutors on create with secondaryTutorEmails, or replace the whole secondary set for one learner with PUT /v1/learners/{learnerId}/secondarytutors.
To manage one tutor across many learners in a single call, use the tutor-centric batch endpoints. They are keyed by the tutor's email and act on a tutor who already exists: they assign or remove a secondary-tutor relationship, they do not create the tutor account, so provision the tutor first with POST /v1/staff or in the app, as above.
- Assign a tutor as a secondary tutor across a batch with
PUT /v1/tutors/{tutorEmail}/secondarytutor/learners, passing thelearnerIdsin the body. Re-assigning a tutor who is already a secondary tutor of a learner is idempotent, so the call is safe to retry. - Replace or remove a tutor across a batch with
PATCH /v1/tutors/{tutorEmail}/secondarytutor/learners. OmitreplacementTutorEmailto remove the tutor; supply one to swap it in, which happens only for learners who already have that tutor as a secondary tutor.
Both return 204 No Content, cap a single request at 50 learners, and are all-or-nothing: if any learner id is unknown, a learner is not eligible for a secondary-tutor change, or the tutor or replacement is already a batched learner's primary tutor, the whole request is rejected with a 400 and nothing is applied. Both require a manager or admin, the same as assigning a tutor above. For the full request and response shape, see the tutor endpoints in the API reference.
Planned: Location as a first-class filterable learner field is on the roadmap.
Tags¶
Tags are your organisation's own labels for learners, such as a course code or a cohort name. A tag carries no meaning to Cognassist: it exists to group learners the way your organisation already thinks about them, and it is separate from courseDetails, which describes what a learner is studying and feeds assessment personalisation.
Tag a learner¶
Reach for this when you want your own grouping to travel with the learner: send the tags on the create itself, or add them later with a separate call as part of your regular sync, and they travel with the learner from then on. Note that nothing in the Cognassist app surfaces tags yet - they are readable and writable through this API, and the in-app views that use them are still to come.
- Add tags with
POST /v1/learners/{learnerId}/tags, sending{"tags": ["Year-2", "cohort:2026"]}. Adding is additive: tags the learner already holds are left alone, and any tag your organisation does not have yet is created for you. Together with the optionaltagslist onPOST /v1/learners, this is how a tag comes into existence over the API - there is no dedicated create-a-tag endpoint. - Remove a tag with
DELETE /v1/learners/{learnerId}/tags?name=Year-2. This detaches the tag from that one learner, leaving your vocabulary and every other learner untouched. - Read a learner's tags with
GET /v1/learners/{learnerId}/tags. It returns the tags that learner currently holds, each as anidand aname, sorted the same way asGET /v1/tags. A learner holding no tags comes back as an empty list, not a404- on this route a404means the learner is unknown. This reads the assignments as they stand, so a tag you have just added or removed is there on the next call.
Three behaviours change how you call these:
- Names match case-insensitively, and the first casing wins.
Closedandclosedare the same tag, and whichever casing created it is the one that displays from then on. Sending a different casing later does not rename it. - Adding and removing are safe to repeat. Adding a tag the learner already holds, or removing one they do not, succeeds and changes nothing.
- You cannot replace a tag or clear a learner's tags in one call. An empty list is deliberately a no-op rather than a delete, so remove a tag and add its replacement as two calls.
A learner may hold at most 10 tags, and a name must be 3 to 20 characters of letters, digits, : or -, with no spaces. Break either rule when adding and you get a 400 naming the problem, with nothing applied. Removal is validated differently, and the difference is worth knowing before you build retries on it. A name that is too short or uses characters outside the set is treated as a name the learner does not hold, so you get 204 and nothing changes - removal is idempotent, and a name nobody holds cannot be removed either way. A name longer than 20 characters once trimmed is the exception: it is rejected with a 400 before that lookup happens. An unknown learner returns 404, as does a learner belonging to another organisation.
Manage your vocabulary¶
Two operations act on your tag vocabulary rather than on a single learner:
- List your tags with
GET /v1/tags. It returns every live tag in your organisation as anidand aname, in the casing the tag was created with, sorted by name. There is no paging and no per-tag learner count: this is the vocabulary, not the assignments. - Delete a tag with
DELETE /v1/tags?name=Year-2. The name is matched case-insensitively, and it is sent as a query parameter rather than a path segment because a tag name may contain characters that are unreliable in a path. You get204 No Contenton success, a400if you send no name, and a404if no live tag has that name.
Tags are identified by name, not by the id the reads return
Every call that changes a tag takes the name: adding, removing from a learner, and deleting across the organisation. None of them accepts the id. That is deliberate - a tag comes into existence by being assigned by name, there is no create-a-tag endpoint handing you an id to store, and an id does not survive a delete (re-creating the same name produces a new one). The id returned by GET /v1/tags and GET /v1/learners/{learnerId}/tags is for correlating a tag across calls, and for filtering learners by tag once that lands.
For the exact request and response shapes, see the tags endpoints in the API reference.
Deleting a tag from your vocabulary affects every learner holding it, and cannot be undone
DELETE /v1/tags is organisation-wide, and there is no restore: the tag is removed from every learner that holds it. To detach a tag from a single learner, use DELETE /v1/learners/{learnerId}/tags instead. Re-creating the same name afterwards produces a new tag with a new id, so anything you stored against the old id will no longer match it.
Filtering or grouping learners by tag is not on the API yet. You can read your vocabulary and any single learner's tags, but there is no way to go the other way and ask for every learner tagged Year-2. That is on the roadmap.
Tagging is switched on per organisation. Until it is switched on for yours, a well-formed, authorised call to any tag route returns 404: ask your Cognassist contact to enable it. The 404 is not a blanket answer for a disabled organisation - a request that is malformed or unauthorised is answered by those checks first, so you may see a 400 or a 403 instead. Treat 404 as "off or absent" and do not read "enabled" into any other status.
Related¶
- Manage staff accounts: provision tutors and other staff, which must exist before you create learners against them.
- Getting started: authenticate and make your first create call.
- Core concepts: join keys and the data model in depth.
- Send the assessment: invite the learner or embed the assessment.
- Keep in sync: webhooks first, polling as the fallback, once a learner is enrolled.
- Power Automate worked example: the reconcile loop as a low-code flow.
- Troubleshooting: diagnosing a rejected create, including the tutor dependency.
- Full request and response schemas: the API reference.