Integration API
Integration API
HTTP endpoints for your systems: create and update riders, record performance flags, assign learning paths, and read completion records.
On this page
Overview#
The API is JSON over HTTPS, under /api/v1 on the platform’s address (the APP_URL your IT team set, for example https://academy.example.com). There are four endpoints:
| Method and path | Purpose |
|---|---|
POST /api/v1/riders | Create or update up to 5,000 riders per call. Runs the automatic assignment rules. |
POST /api/v1/flags | Record a performance flag. Assigns matching corrective training and can send the rider a link. |
POST /api/v1/assignments | Assign a learning path to riders by rider ID and/or filters. |
GET /api/v1/completions | Read passed course records, oldest first, page by page. |
The examples use two shell variables:
export BASE="https://academy.example.com"
export KEY="<your INTEGRATION_API_KEY>"What the API doesn’t do
How data flows#
Rider system
Sends new and changed riders to POST /riders.
Assignment rules
Onboarding and auto-assign paths are given to matching riders.
Ops systems
Send flags to POST /flags. Corrective paths are assigned, links sent.
Riders train
On their phone, in their language.
Analytics
Reads passed courses from GET /completions.
- Riders: send riders when they join and whenever their market, vehicle, language or status changes. New riders with status
onboardingget the onboarding path. - Flags: send a flag when a performance issue is confirmed. The rider must already exist.
- Assignments: for campaigns, for example a new policy for every active rider in one market.
- Completions: poll regularly (for example hourly) and store the records in your data warehouse.
The training team can do the same by hand in the admin panel: CSV import for riders (useful for bucket files), flags and Assign now on the rider and path pages, and CSV exports.
Authentication#
Every request must carry the integration key that IT set on the server in INTEGRATION_API_KEY, in one of these headers:
x-api-key: <INTEGRATION_API_KEY>
Authorization: Bearer <INTEGRATION_API_KEY>- If both headers are sent,
x-api-keyis used. - The key must match exactly (it is case-sensitive).
- There is one key for all integrations. To rotate it, IT changes the variable and restarts the app; the old key stops working at once.
| Status | Body | When |
|---|---|---|
| 401 | {"error":"Invalid API key","code":"unauthorized"} | The header is missing or the key is wrong. |
| 503 | {"error":"The integration API is disabled. Set INTEGRATION_API_KEY on the server.","code":"api_disabled"} | No key is configured on the server. |
Conventions#
- Send JSON request bodies with
Content-Type: application/json. Responses are JSON. - Every error has the same JSON body:
{"error":"message","code":"invalid_request"}.erroris a readable message;codeis stable for your code to check:invalid_request,unauthorized,not_found,conflict,internal_errororapi_disabled. The riders endpoint also returns per-row errors (see below). - Rider IDs are rider IDs sent as strings:
"300001", not300001. - Don’t send
nullfor optional fields: leave the field out. Unknown fields are ignored. - Timestamps are ISO 8601 in UTC, for example
2026-09-17T08:30:12.345Z. - A write that clashes with a change made at the same moment returns 409 with code
conflict: retry it. An unexpected server error returns 500 with codeinternal_error: retry later; IT can check the logs.
Create or update riders#
POST/api/v1/riders
Creates new riders and updates existing ones, matched on riderId. After saving, the automatic learning path rules (“Auto-assign to every matching rider” and “Riders in onboarding status”) run for all riders.
Request body#
| Field | Type | Rules | |
|---|---|---|---|
riders | array of rider objects | Required | 1 to 5,000 items. |
Each rider object:
| Field | Type | Rules | |
|---|---|---|---|
riderId | string | Required | 1 to 64 characters after trimming spaces. The rider ID and the match key. |
name | string | Required | 1 to 200 characters. Replaces the stored name. |
market | string | Required | A code (AE, KW, QA, BH, OM, JO, EG, IQ) or a market name (UAE, Kuwait, Qatar, Bahrain, Oman, Jordan, Egypt, Iraq), in any case. Stored as the code. Replaces the stored market. |
phone | string | Optional | Up to 32 characters. International format, for example +96550000001. Used for WhatsApp. |
city | string | Optional | Up to 100 characters. |
vehicleType | string | Optional | Up to 40 characters, stored in lower case. Use motorbike, car, bicycle, scooter or walker: other values are accepted but never match learning path filters. |
nationality | string | Optional | Up to 60 characters. Must equal a learning path option (for example Pakistani) to be used by filters. |
language | string | Optional | en, ar, ur or ku. Any case. New riders default to en. |
status | string | Optional | onboarding, active, suspended or inactive, in lower case. New riders default to active. |
pin | string | Optional | 4 to 8 digits. Sets or replaces the rider’s PIN. |
- Optional fields that you leave out, or send as an empty string, keep their current value. There is no way to clear a field.
- If the same
riderIdappears twice in one request, the last one wins. - For large files, send batches of up to 5,000 riders one after the other. Each call runs the assignment rules.
Example#
curl -X POST "$BASE/api/v1/riders" \
-H "x-api-key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"riders": [
{
"riderId": "300001",
"name": "Ali Hassan",
"market": "KW",
"city": "Hawalli",
"vehicleType": "motorbike",
"nationality": "Egyptian",
"language": "ar",
"status": "onboarding",
"phone": "+96550000001"
},
{ "riderId": "300002", "name": "Imran Ali", "market": "XX" }
]
}'Response, status 200 (one rider saved, one rejected):
{
"inserted": 1,
"updated": 0,
"enrollmentsCreated": 1,
"errors": [
{ "index": 1, "error": "market must be one of AE, KW, QA, BH, OM, JO, EG, IQ (or the market name, e.g. Qatar)" }
]
}Response and errors#
| Field | Type | Meaning |
|---|---|---|
inserted | number | New riders created. |
updated | number | Existing riders updated. |
enrollmentsCreated | number | New assignments created by the automatic rules, across all riders (not only the ones in this request). |
errors | array | Rejected riders: { "index": number, "error": string }, where index is the zero-based position in riders. The message describes the first problem found. |
| Status | When |
|---|---|
| 200 | At least one rider was saved. Check errors for rejected riders. |
| 400 | Every rider was rejected: {"error":"None of the riders are valid. See errors.","code":"invalid_request","inserted":0,"updated":0,"enrollmentsCreated":0,"errors":[...]} |
| 400 | {"error":"Send { riders: [...] } with 1 to 5000 riders.","code":"invalid_request"}: the body isn’t valid JSON, riders is missing or empty, or has more than 5,000 items. |
| 409, 500 | See Status codes. |
| 401, 503 | See Authentication. |
Examples of row errors:
| Problem | error |
|---|---|
| riderId or name missing | Invalid input: expected string, received undefined |
| riderId sent as a number | Invalid input: expected string, received number |
| Unknown market | market must be one of AE, KW, QA, BH, OM, JO, EG, IQ (or the market name, e.g. Qatar) |
| Unknown language | language must be en, ar, ur or ku |
| Unknown or capitalised status | Invalid option: expected one of "onboarding"|"active"|"suspended"|"inactive" |
| PIN not 4 to 8 digits | pin must be 4 to 8 digits |
| Field too long | Too big: expected string to have <=64 characters |
Record a flag#
POST/api/v1/flags
Records a performance flag for an existing rider and assigns matching corrective training straight away.
| Field | Type | Rules | |
|---|---|---|---|
riderId | string | Required | The rider ID, exactly as stored (not trimmed). |
type | string | Required | One of: late_delivery, customer_complaint, unsafe_driving, food_handling, equipment, cancellations. |
note | string | Optional | Up to 1,000 characters. Shown on the rider page and in reports. |
notify | boolean | Optional | true sends the rider a one-tap link to their training when at least one path was assigned. Default false. |
What happens:
- The flag is saved with source “api” and shows on the rider page as “via api”.
- Every active learning path set to “When a rider gets a flag” with this flag type is assigned, if the rider matches the path’s market, vehicle and nationality filters, isn’t inactive, and doesn’t already have that path open.
- With
notify: true, the link is sent on WhatsApp when the Cloud API is connected and the rider has a phone number. Otherwise it is logged for the training team to share.
curl -X POST "$BASE/api/v1/flags" \
-H "x-api-key: $KEY" \
-H "Content-Type: application/json" \
-d '{"riderId":"300001","type":"customer_complaint","note":"Order left at the wrong door","notify":true}'Response, status 200:
{
"flagId": "5b0c1f3e-8d2a-4c7e-9a41-2f6d8e3b7c10",
"enrollmentsCreated": 1,
"notification": "logged"
}| Field | Type | Meaning |
|---|---|---|
flagId | string (UUID) | ID of the saved flag. |
enrollmentsCreated | number | Corrective paths assigned. 0 if none matched. |
notification | string or null | null: notify wasn’t requested or nothing was assigned. "sent": delivered to the WhatsApp Cloud API. "failed": WhatsApp returned an error. "logged": not sent automatically. |
| Status | Body |
|---|---|
| 200 | As above. |
| 400 | {"error":"Send { riderId, type, note?, notify? }. type is one of: late_delivery, customer_complaint, unsafe_driving, food_handling, equipment, cancellations","code":"invalid_request"} |
| 404 | {"error":"Unknown riderId. Create the rider first with POST /api/v1/riders.","code":"not_found"} |
| 409, 500 | See Status codes. |
| 401, 503 | See Authentication. |
Not idempotent
Assign a learning path#
POST/api/v1/assignments
Assigns a learning path to riders chosen by rider ID, by filters, or both. It works like Assign now in the admin panel.
| Field | Type | Rules | |
|---|---|---|---|
pathId | string (UUID) | Required | The learning path ID: the last part of the path page address, /admin/paths/<pathId>. |
riderIds | array of strings | Optional | Up to 250,000 rider IDs. |
markets | array of strings | Optional | Market codes in upper case, for example ["EG", "JO"]. |
vehicleTypes | array of strings | Optional | Lower case, for example ["motorbike"]. |
statuses | array of strings | Optional | onboarding, active, suspended, inactive. |
- Give riderIds, at least one filter, or both.
- All criteria combine: a rider must match every filter and be in riderIds if given. Within one array, any value matches.
- Filter values are matched exactly, so use upper-case market codes and lower-case vehicle types and statuses.
- Riders with status inactive are never assigned. Unknown rider IDs are ignored.
- Riders who already have the path open are skipped. Riders who finished it before are assigned again; courses they already passed count, so the assignment can complete straight away (see How assignments are completed).
- The path’s own rule, filters and Active setting are ignored. The deadline is now plus the path’s days to complete.
- No message is sent to riders. The rider page shows the assignment with Why “api”.
- The city filter of the admin panel isn’t available in the API.
# Every active rider in Egypt and Jordan
curl -X POST "$BASE/api/v1/assignments" \
-H "x-api-key: $KEY" \
-H "Content-Type: application/json" \
-d '{"pathId":"8f14e45f-ceea-467a-9575-1a2b3c4d5e6f","markets":["EG","JO"],"statuses":["active"]}'
# Specific riders
curl -X POST "$BASE/api/v1/assignments" \
-H "x-api-key: $KEY" \
-H "Content-Type: application/json" \
-d '{"pathId":"8f14e45f-ceea-467a-9575-1a2b3c4d5e6f","riderIds":["300001","300002","300003"]}'Response, status 200:
{ "enrollmentsCreated": 1250 }| Status | Body | When |
|---|---|---|
| 200 | {"enrollmentsCreated": number} | New assignments created. |
| 400 | {"error":"Send { pathId, riderIds?, markets?, vehicleTypes?, statuses? }.","code":"invalid_request"} | Invalid JSON, pathId isn’t a UUID, or a field has the wrong type. |
| 400 | {"error":"Give riderIds or at least one filter.","code":"invalid_request"} | No rider IDs and no filters. |
| 404 | {"error":"Learning path not found","code":"not_found"} | No path with that ID. |
| 409, 500 | See Status codes. | |
| 401, 503 | See Authentication. |
Read completion records#
GET/api/v1/completions?cursor=<nextCursor>&limit=<1-5000>
Returns passed course records, one per rider per course, ordered by completion time, oldest first. Use it to copy completions into your analytics.
| Parameter | Type | Rules | |
|---|---|---|---|
cursor | string | Optional | The nextCursor value from the previous page. When given, since is ignored. Any other value returns 400. |
since | ISO 8601 date-time | Optional | For the first request: only records completed strictly after this moment. Default: all records. |
limit | integer | Optional | Records per page, 1 to 5,000. Default 1,000. Higher values are capped at 5,000; 0 or text means 1,000. |
curl -G "$BASE/api/v1/completions" \
-H "x-api-key: $KEY" \
--data-urlencode "since=2026-09-01T00:00:00Z" \
--data-urlencode "limit=1000"Response, status 200:
{
"data": [
{
"riderId": "300001",
"market": "KW",
"courseId": "0b6c7d9e-3f1a-4b2c-8d5e-6f7a8b9c0d1e",
"course": "Food safety on the road",
"language": "ar",
"startedAt": "2026-09-02T10:14:03.120Z",
"completedAt": "2026-09-02T10:21:47.908Z",
"score": 80,
"attempts": 2,
"timeSpentSeconds": 402,
"proofCode": "3F9A1C0B"
}
],
"next": null,
"nextCursor": null
}| Field | Type | Meaning |
|---|---|---|
riderId | string | rider ID. |
market | string | Market code of the rider now. |
courseId | string (UUID) | Course ID in the platform. |
course | string or null | English course title. |
language | string | Language the rider took the course in: en, ar, ur or ku. |
startedAt | string | When the rider first opened the course. |
completedAt | string | When the rider first passed. It doesn’t change if they retake the quiz. |
score | number or null | Best quiz score, 0 to 100 (null for a course without a quiz). A retake can raise it, never lower it. |
attempts | number | Quiz attempts so far. |
timeSpentSeconds | number | Time spent in the course. |
proofCode | string | 8-character verification code, as on the rider’s proof of completion. |
nextCursor | string or null | Top level: pass it as cursor to read the next page. null when this page isn’t full, so there is nothing more for now. |
next | string or null | Top level: completedAt of the last record when the page is full. Kept for older integrations. Paging with since=next can skip records that share a completion time at a page boundary: use nextCursor. |
| Status | Body |
|---|---|
| 200 | As above. |
| 400 | {"error":"cursor must be the nextCursor value from a previous page","code":"invalid_request"} |
| 400 | {"error":"since must be an ISO date","code":"invalid_request"} |
| 500 | See Status codes. |
| 401, 503 | See Authentication. |
Reading every record#
- First request:
since(or nothing, for everything). Then, whilenextCursorisn’t null, call again withcursor=nextCursor. - When a page comes back with
nextCursornull, you have everything for now. To pick up new completions later, start again withsinceset to thecompletedAtof the last record you stored (you may receive that record again), then page withcursor. Don’t page withsince=next: it can skip records that share a completion time. - Paging with
cursoris stable: no record is skipped or repeated between pages, even when several records share the same completion time. UseproofCode(orriderId+courseId) as the key when storing records, so reading a record twice is harmless. - The feed covers every market and every rider status. It doesn’t include riders who opened a course but haven’t passed; use the admin panel export for those.
page=$(curl -sfG "$BASE/api/v1/completions" -H "x-api-key: $KEY" \
--data-urlencode "since=2026-09-01T00:00:00Z" --data-urlencode "limit=1000") || exit 1
while :; do
echo "$page" | jq -c '.data[]' >> completions.jsonl
cursor=$(echo "$page" | jq -r '.nextCursor // empty')
[ -z "$cursor" ] && break
page=$(curl -sfG "$BASE/api/v1/completions" -H "x-api-key: $KEY" \
--data-urlencode "cursor=$cursor" --data-urlencode "limit=1000") || exit 1
doneStatus codes#
Every error body is {"error":"message","code":"..."}.
| Status | code | Meaning | What to do |
|---|---|---|---|
| 200 | - | Success. For riders, check errors. | - |
| 400 | invalid_request | The request is invalid. | Read error, fix the request. Don’t retry unchanged. |
| 401 | unauthorized | Missing or wrong API key. | Check the header and the key. |
| 404 | not_found | Unknown rider (flags) or learning path (assignments). | Create the rider first, or check the pathId. |
| 409 | conflict | The request clashed with a change made at the same time, for example two calls updating the same rider. | Retry the request. |
| 500 | internal_error | Unexpected server error. Details are only in the server log. | Retry later with backoff. Tell IT if it continues. |
| 503 | api_disabled | The API is switched off on the server. | Ask IT to set INTEGRATION_API_KEY and restart. |
Testing an integration#
- Use a test rider ID that can’t clash with real riders. Test riders count in the dashboard and reports, so keep them few and easy to recognise.
- Create the rider with POST /riders, then check it in the admin panel under Riders.
- Send a flag with notify: false and check the rider page shows the flag “via api” and the assigned path.
- Pass a course as the test rider in the rider app, then read it back with GET /completions.
- Set the test rider’s status to
inactivewhen you’re done. There’s no delete endpoint.