token — include it as Authorization: Bearer <token> on all protected routes. Tokens expire after 7 days.| Field | Type | Required | Notes |
|---|---|---|---|
| string | required | Stored lowercase | |
| password | string | required | Min 8 characters |
201 Returns JWT token and email. Account is immediately active.
{ "token": "eyJ...", "email": "user@example.com" }
400 Missing fields or password too short 409 Email already registered
| Field | Type | Required | Notes |
|---|---|---|---|
| string | required | ||
| password | string | required |
200 JWT token and email
{ "token": "eyJ...", "email": "user@example.com" }
401 Invalid email or password
| Field | Type | Required | Notes |
|---|---|---|---|
| string | required |
Always returns 200 to prevent email enumeration. Sends a reset link to the email fire-and-forget. Reset token is valid for 1 hour.
{ "message": "If that email exists, a reset link has been sent" }
| Field | Type | Required | Notes |
|---|---|---|---|
| token | string | required | From reset email link |
| password | string | required | Min 8 characters |
200
{ "message": "Password updated successfully" }
400 Invalid/expired token, or password too short
Returns the learner row for the authenticated user, or null if no data saved yet.
{
"user_id": 1,
"first_name": "Ian",
"surname": "Stockley",
"progress_step": 3,
"submitted": 0,
"completed": 0,
...all learner fields
}
Called after each step to persist progress. All fields are optional — only provided fields overwrite existing values.
| Field | Type | Notes |
|---|---|---|
| progress_step | integer | Current step number (1–11) |
| first_name, surname, middle_name, known_as | string | Personal details |
| date_of_birth | string | DD/MM/YYYY |
| legal_sex, pronoun, ethnicity, title | string | |
| address_line1, address_line2, address_town, address_postcode | string | |
| years_at_address | string | |
| lived_3_years | integer | 0 or 1 |
| prev_address_line1, prev_address_line2, prev_address_town, prev_address_postcode | string | If lived_3_years = 0 |
| phone_mobile, phone_landline | string | |
| euro_resident_id | integer | 0 or 1 |
| country_of_birth | string | |
| has_disability | integer | 0 or 1 |
| disability_category_1, disability_category_2 | string | If has_disability = 1 |
| has_ehcp, learning_barrier, exam_arrangements | integer | 0 or 1 |
| disability_notes | string | |
| ec1_first_name, ec1_surname, ec1_phone, ec1_mobile, ec1_email, ec1_relationship, ec1_living_with | string/int | Emergency Contact 1 |
| ec2_first_name, ec2_surname, ec2_phone, ec2_mobile, ec2_email, ec2_relationship, ec2_living_with | string/int | Emergency Contact 2 (optional) |
| employment_status, benefit_type | string | |
| residency_status, right_to_work | string | |
| prior_attainment_level | string | |
| quals_on_entry | string | JSON array of qualification objects |
| id_document_type | string | "Passport" or "Driving Licence" |
| id_number | string | |
| dl_surname, dl_first_names, dl_dob | string | Driving licence only |
| heard_about_scl | string | |
| contact_by_sms, contact_by_email, contact_by_social_media, contact_by_website | integer | 0 or 1 |
| declaration_confirmed | integer | 0 or 1 |
{ "ok": true }
Sets submitted = 1 and completed = 1 on the learner record. Fires a confirmation email (non-fatal if it fails). Call after /save with progress_step: 11.
{ "ok": true }
| Field | Type | Required | Notes |
|---|---|---|---|
| token | string | required | Expo push token (iOS only) |
{ "ok": true }
Returns notifications ordered by unread first, then newest first.
[
{
"id": 12,
"title": "Application update",
"body": "Your application has been reviewed.",
"read": 0,
"created_at": "2026-03-05 10:32:00"
}
]
Only marks as read if the notification belongs to the authenticated user.
{ "ok": true }
{ "ok": true }
x-admin-password request header. The password is set via the ADMIN_PASSWORD environment variable in backend/.env. Returns 401 if missing or wrong.Ordered newest first. Includes message counts.
[
{
"id": 1,
"email": "ian@example.com",
"created_at": "2026-03-01 09:00:00",
"full_name": "Ian Stockley",
"submitted": 0,
"completed": 0,
"progress_step": 4,
"msg_count": 2,
"unread_count": 1
}
]
Returns all learner form fields plus account metadata.
{
"email": "ian@example.com",
"registered_at": "2026-03-01 09:00:00",
"submitted_at": null,
"progress_step": 4,
"submitted": 0,
"completed": 0,
"first_name": "Ian",
"surname": "Stockley",
...all form fields
}
404 User not found
Returns notifications sent to this learner, newest first.
[
{
"id": 5,
"title": "Next steps",
"body": "Please bring your ID to your first session.",
"read": 1,
"created_at": "2026-03-04 14:00:00"
}
]
Resets submitted and completed to 0, allowing the learner to edit and resubmit their application.
{ "ok": true }
Inserts a notification row per user. Push notification sent fire-and-forget to any iOS device tokens registered.
| Field | Type | Required | Notes |
|---|---|---|---|
| userIds | string | integer[] | required | "all" or array of user IDs (max 1000) |
| title | string | required | Max 80 characters |
| body | string | required | Max 500 characters |
{
"userIds": [1, 3, 7],
"title": "Reminder",
"body": "Don't forget to complete step 5 of your application."
}
{ "sent": 3 }
[
{
"id": 1,
"name": "Day 3 nudge",
"trigger_type": "stuck_step",
"trigger_step": 3,
"trigger_field": null,
"trigger_days": 3,
"message_title": "Still there?",
"message_body": "You left off at step 3 — finish now!",
"active": 1,
"created_at": "2026-03-01 09:00:00",
"send_count": 12
}
]
| Field | Type | Required | Notes |
|---|---|---|---|
| name | string | required | Max 100 characters |
| trigger_type | string | required | not_started · stuck_step · awaiting · blank_field |
| trigger_step | integer | optional | Required if trigger_type = stuck_step (1–11) |
| trigger_field | string | optional | Required if trigger_type = blank_field |
| trigger_days | integer | required | Days to wait before firing (1–365) |
| message_title | string | required | Max 80 characters |
| message_body | string | required | Max 500 characters |
| Value | Description |
|---|---|
| not_started | Registered but progress_step is 0 for N days |
| stuck_step | Stuck on a specific step for N days (requires trigger_step) |
| awaiting | Submitted but not yet marked complete for N days |
| blank_field | A specific learner field is still empty after N days (requires trigger_field) |
{ "id": 4 }
| Field | Type | Required | Notes |
|---|---|---|---|
| active | boolean | required | true to enable, false to pause |
{ "ok": true }
Permanently deletes the workflow and its send history. Cannot be undone.
{ "ok": true }