Number & Tag API Operations Guide
1. Purpose
This guide is for operators driving the number-and-tag lifecycle directly against the admin-service REST API. It exists because the admin-ui for these workflows is not yet operational — every action below will eventually have a screen, but until then these are the supported entry points.
Covered in this guide:
-
Listing current stock and filtering by state, type, subtype, or person
-
Batch-returning numbers after an event or stock take
-
Flagging numbers as unfit for service
-
Disposing of unfit numbers (transition to DESTROYED)
-
Downloading the pre-event timing feed CSV (RaceDay Scoring)
-
Manually (re)assigning a number to an EventParticipant
For the design behind these endpoints see the Number & Tag Runbook (manual DB procedures) and design journal 2026-03/number-tag-management.adoc. For the lifecycle state machine see State Model (Recap) below.
2. Audience & Prerequisites
You need:
-
An ADMIN-role API key — contact the system administrator for your key, or generate one by adding an
api_key_permissionrow withrole = 'ADMIN'against your existing API key. Do not share keys. Do not embed keys in scripts committed to git. -
The target environment’s base URL (see Environment Base URLs).
-
A terminal with
curlandjq. Examples below assume both are on the$PATH. -
Awareness of which environment you are hitting. Production writes are real — rehearse in Dev first.
Assumed reading: Management API Access for the X-API-KEY authentication model.
3. Authentication
All endpoints below are secured with hasAnyAuthority('ROLE_USER', 'ROLE_API_KEY'). For automated/operator use, present an API key via the X-API-KEY header:
export API_KEY="<your-api-key-uuid>"
curl -H "X-API-KEY: $API_KEY" "$BASE_URL/api/race-numbers/stock"
The key must:
-
Be marked
active = 'Y'in theapi_keytable -
Have at least one matching
api_key_permissionrow -
Belong to an organisation with access to the data you are reading or writing (composite security; see Common Design — Security)
In all examples below $API_KEY refers to your own API key. Never paste a key into an example you plan to share or commit.
4. Environment Base URLs
| Environment | Base URL |
|---|---|
Production |
|
Stage |
|
Dev |
Export once per shell session:
export BASE_URL="https://admin-service-dev.idealogic.co.za"
export API_KEY="<your-api-key-uuid>"
5. Quick Reference
| Task | Endpoint | Notes |
|---|---|---|
List / filter stock |
|
Paginated; any |
Batch return |
|
Stock-take scan or post-event reconciliation. |
Flag unfit |
|
Soft-retirement; keeps |
Dispose |
|
Terminal; requires prior |
Timing feed CSV |
|
Pre-event export for RaceDay Scoring. |
Manual (re)assignment |
|
Update |
6. State Model (Recap)
Every RaceNumber is in exactly one of six states, persisted as a single char in race_number.state:
| Code | State | Meaning |
|---|---|---|
|
MANUFACTURED |
Onboarded but tag pairing pending. Not assignable. |
|
IN_STOCK |
Available for assignment. |
|
ISSUED |
Held by a person for use at an event. |
|
IN_USE |
Detected by timing at an event. |
|
UNFIT_FOR_SERVICE |
Retired from auto-assignment. May still be held ( |
|
DESTROYED |
Terminal. Disposed of physically. |
The full state machine and transition rules live in the design journal (2026-03/number-tag-management.adoc, sessions 1-3).
7. Stock View
GET /api/race-numbers/stock — paginated list with filter composition. The full RaceNumberCriteria is accepted; every filter supports equals, notEquals, in, and specified where the shape allows.
7.1. Common filters
| Query param | Meaning | Example |
|---|---|---|
|
Exact state code |
|
|
Comma-separated codes |
|
|
NumberType primary key |
|
|
Subtype primary key |
|
|
Holder (wp_users id) |
|
|
ISO-8601 Instant |
|
|
Field + direction |
|
|
Pagination |
|
7.2. Example: available stock for a given NumberType
curl -s -H "X-API-KEY: $API_KEY" \
"$BASE_URL/api/race-numbers/stock?state.in=S,F&typeId.equals=78&sort=sequence,asc&size=200" \
| jq '.content[] | {id, number, sequence, state}'
7.3. Example: all numbers currently held by a person
curl -s -H "X-API-KEY: $API_KEY" \
"$BASE_URL/api/race-numbers/stock?personId.equals=1234&sort=lastUsed,desc" \
| jq '.content[] | {number, state, lastUsed}'
7.4. Response shape
A paginated Page<RaceNumberDTO> with content (array), totalElements, totalPages, number (page index), size. Each entry:
{
"id": 2418,
"number": "123",
"sequence": 123,
"colour": null,
"state": "ISSUED",
"lastUsed": "2026-03-12T08:00:00Z",
"validFrom": "2023-01-01T00:00:00Z",
"validTo": null,
"type": { "id": 78, "name": "PS Numbers" },
"subType": null,
"person": { "id": 1234, "name": "Jane Doe" }
}
8. Batch Return
POST /api/race-numbers/return — marks numbers as returned to stock. Applied per RaceNumber:
-
ISSUED/IN_USE→IN_STOCK, clearsperson_id, logsRETURNED -
UNFIT_FOR_SERVICE→ state preserved, clearsperson_id, logsRETURNEDwith "recommend disposal" prepended to the note -
IN_STOCK/DESTROYED/MANUFACTURED→ no-op (idempotent — no log row written) -
Unknown id →
NOT_FOUNDentry in the response
EP linkages (event_participant.number_id) are never cleared — the historical result remains linked.
8.2. Example
curl -s -X POST -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \
"$BASE_URL/api/race-numbers/return" \
--data '{"numberIds":[2418,2419,2420],"note":"Post-event 119 return scan"}' \
| jq
8.3. Response shape
{
"returned": 2,
"unfitRecommendedForDisposal": 1,
"noOp": 0,
"notFound": 1,
"details": [
{ "numberId": 2418, "number": "123", "oldState": "ISSUED",
"newState": "IN_STOCK", "action": "RETURNED" },
{ "numberId": 2419, "number": "124", "oldState": "UNFIT_FOR_SERVICE",
"newState": "UNFIT_FOR_SERVICE", "action": "DISPOSAL_RECOMMENDED" },
{ "numberId": 2420, "number": "125", "oldState": "ISSUED",
"newState": "IN_STOCK", "action": "RETURNED" },
{ "numberId": 9999999, "action": "NOT_FOUND" }
]
}
9. Flag Unfit
POST /api/race-numbers/flag-unfit — flags numbers as UNFIT_FOR_SERVICE. person_id is not cleared: the flag is location-agnostic (participant may still be physically holding the number; the RFID is still detectable by the timing system).
Per id:
-
Any state other than UNFIT / DESTROYED →
UNFIT_FOR_SERVICE+FLAGGED_UNFITlog row -
Already UNFIT →
SKIPPED_ALREADY_UNFIT, no state change, no log -
DESTROYED →
SKIPPED_DESTROYED, no state change, no log -
Unknown id →
NOT_FOUND
9.1. Request
{
"numberIds": [2418, 2419, 2420],
"note": "Damaged at event 119 — printing illegible"
}
9.2. Example
curl -s -X POST -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \
"$BASE_URL/api/race-numbers/flag-unfit" \
--data '{"numberIds":[2418,2419,2420],"note":"Damaged at event 119"}' \
| jq
9.3. Response shape
{
"success": 2,
"skipped": 1,
"rejected": 0,
"notFound": 0,
"details": [
{ "numberId": 2418, "number": "123", "oldState": "ISSUED",
"newState": "UNFIT_FOR_SERVICE", "outcome": "FLAGGED" },
{ "numberId": 2419, "number": "124", "oldState": "IN_STOCK",
"newState": "UNFIT_FOR_SERVICE", "outcome": "FLAGGED" },
{ "numberId": 2420, "number": "125", "oldState": "UNFIT_FOR_SERVICE",
"newState": "UNFIT_FOR_SERVICE", "outcome": "SKIPPED_ALREADY_UNFIT" }
]
}
10. Dispose
POST /api/race-numbers/dispose — transitions UNFIT_FOR_SERVICE numbers to DESTROYED and clears person_id. Terminal: a DESTROYED number cannot be reassigned, revived, or re-flagged.
Disposal requires prior flagging. Any state other than UNFIT produces REJECTED_NOT_UNFIT — the dispose call does not implicitly flag.
Per id:
-
UNFIT_FOR_SERVICE→DESTROYED, clearsperson_id, logsDISPOSED -
Any other state →
REJECTED_NOT_UNFIT, no state change, no log -
Unknown id →
NOT_FOUND
11. Timing Feed CSV
GET /api/events/{eventId}/timing-feed — streams a CSV for the event, one row per EventParticipant, suitable for loading into RaceDay Scoring.
Columns emitted, in order:
-
External Reference ID—EventParticipant.id -
First—EP.firstName -
Last—EP.lastName -
Gender— single char:M,F, orU -
DOB—yyyy-MM-dd(blank if not set) -
Event Category—EP.category.name -
Bib—EP.number.number(blank if no number assigned) -
{Event.customList1Name}— dynamic; only emitted ifEvent.customList1Nameis set; value fromEP.custom1.name -
{Event.customList2Name}— same rule for slot 2 -
{Event.customList3Name}— same rule for slot 3
The response is Content-Type: text/csv with Content-Disposition: attachment; filename="timing-feed-{eventId}.csv". Streaming, UTF-8, RFC-4180 quoting (fields containing ,, ", or newlines are double-quoted).
|
WS1b scope limitation: |
11.1. Example
curl -s -H "X-API-KEY: $API_KEY" \
-H "Accept: text/csv" \
"$BASE_URL/api/events/119/timing-feed" \
-o timing-feed-119.csv
head -3 timing-feed-119.csv
Expected header line:
External Reference ID,First,Last,Gender,DOB,Event Category,Bib
With custom list columns named Team and Waiver:
External Reference ID,First,Last,Gender,DOB,Event Category,Bib,Team,Waiver
12. Manual (Re)Assignment of a Number
PUT /api/event-participants/{id} is the supported entry point for changing an EventParticipant’s number. It accepts the full EventParticipantDTO body; the server applies the delta. When number.id changes (NULL → value or value → other value), the service:
-
Validates the transition on the new RaceNumber (IN_STOCK → ISSUED, or carry-over on ISSUED/IN_USE for same person; rejects UNFIT / DESTROYED / MANUFACTURED)
-
Sets
race_number.person_idif moving to ISSUED -
Stamps
race_number.last_used = now()on every assignment path (see US #476) -
Writes a
RaceNumberStateLogrow with reasonASSIGNED(first-time) orREASSIGNED(swap)
|
|
12.1. Fetch first, then update
EP_ID=987654
# 1. Fetch current state
curl -s -H "X-API-KEY: $API_KEY" \
"$BASE_URL/api/event-participants/$EP_ID" \
| jq > ep.json
# 2. Edit ep.json in your editor: change "number": { "id": <newNumberId> }
# Ensure the object still has its id, event, person, category, etc.
# 3. PUT the updated body back
curl -s -X PUT -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \
"$BASE_URL/api/event-participants/$EP_ID" \
--data @ep.json \
| jq
12.2. PATCH for a minimal change (recommended for "just change the number")
curl -s -X PATCH \
-H "X-API-KEY: $API_KEY" \
-H "Content-Type: application/merge-patch+json" \
"$BASE_URL/api/event-participants/$EP_ID" \
--data '{"id": '"$EP_ID"', "number": { "id": 2525 }}' \
| jq
12.3. What can go wrong
| Error | Cause |
|---|---|
|
Path id must match body id and body id must be non-null. |
|
Your key lacks access to the event’s organisation OR the participant’s person. |
|
Target RaceNumber is terminally retired; pick another. |
|
Target is flagged; either dispose & replace, or coordinate with an admin to un-flag (no API for un-flag today — SQL). |
|
Onboarding incomplete; tag pairing still pending. Finish onboarding first (out of WS1a/b scope). |
|
Number is ISSUED/IN_USE to a different person. Return it first ( |
|
Cascade to future EPs is not yet implemented. Today a manual reassignment on EP 987654 does not propagate to the same person’s EPs on other future events. Tracked as US #505 — until that ships, operators doing a damaged-number replacement for a series must PUT/PATCH each affected EP manually. See design journal Session 5 (2026-04-23). |
13. Common Recipes
13.1. Return every issued board after an event
Given an event id 119 with its assigned NumberType 78:
# 1. List everything ISSUED/IN_USE for that type
IDS=$(curl -s -H "X-API-KEY: $API_KEY" \
"$BASE_URL/api/race-numbers/stock?state.in=I,U&typeId.equals=78&size=500" \
| jq -r '.content[].id' | paste -sd, -)
# 2. Batch return them
curl -s -X POST -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \
"$BASE_URL/api/race-numbers/return" \
--data "{\"numberIds\":[$IDS],\"note\":\"Event 119 post-event return\"}" \
| jq '{returned, unfitRecommendedForDisposal, noOp, notFound}'
Adjust the stock filter to scope more tightly (e.g. by personId.in) if the event pool is mixed with carry-over numbers that should not be returned.
13.2. Flag a list of damaged numbers from a spreadsheet column of ids
# file damaged-ids.txt: one numberId per line
IDS=$(paste -sd, damaged-ids.txt)
curl -s -X POST -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \
"$BASE_URL/api/race-numbers/flag-unfit" \
--data "{\"numberIds\":[$IDS],\"note\":\"Damaged at event 119\"}" \
| jq
13.3. Dispose every UNFIT number currently in our possession
"In our possession" means state = UNFIT_FOR_SERVICE AND person_id IS NULL (they have been returned, so no retrieval coordination is needed).
IDS=$(curl -s -H "X-API-KEY: $API_KEY" \
"$BASE_URL/api/race-numbers/stock?state.equals=F&personId.specified=false&size=500" \
| jq -r '.content[].id' | paste -sd, -)
curl -s -X POST -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \
"$BASE_URL/api/race-numbers/dispose" \
--data "{\"numberIds\":[$IDS],\"note\":\"Disposal batch 2026-04-23\"}" \
| jq '{success, rejected, notFound}'
13.4. Download the timing feed for the next event
EVENT_ID=120
curl -s -H "X-API-KEY: $API_KEY" -H "Accept: text/csv" \
"$BASE_URL/api/events/$EVENT_ID/timing-feed" \
-o timing-feed-$EVENT_ID.csv
wc -l timing-feed-$EVENT_ID.csv
head -1 timing-feed-$EVENT_ID.csv # inspect the header
13.5. Reassign a damaged number mid-series (manual cascade)
Until US #505 ships, if a participant (person 1234) reports a damaged number on event 119 and is also entered for events 120 and 121, do this per-EP:
# 1. Find every EP for that person with the damaged number
# (use the stock view to locate the RaceNumber id, then query event-participants)
# For each affected EP:
curl -s -X PATCH \
-H "X-API-KEY: $API_KEY" \
-H "Content-Type: application/merge-patch+json" \
"$BASE_URL/api/event-participants/$EP_ID" \
--data '{"id": '"$EP_ID"', "number": { "id": <newNumberId> }}'
Flag the old number as unfit once it is physically in hand:
curl -s -X POST -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \
"$BASE_URL/api/race-numbers/flag-unfit" \
--data '{"numberIds":[<oldNumberId>],"note":"Damaged — replaced on event 119"}'
14. HTTP Status Reference
| Status | Meaning | Common causes |
|---|---|---|
|
Success |
Any successful GET/POST/PUT/PATCH |
|
Client error in the request |
Invalid body, id mismatch, missing required field |
|
Missing or invalid API key |
Check |
|
Authenticated but not permitted |
Composite security check failed — no access to event org or participant person |
|
Resource doesn’t exist |
Wrong id, wrong environment |
|
Optimistic concurrency failure |
DTO |
|
Unhandled exception |
|
15. Out of Scope (For Now)
The following are not yet addressed by the API and require SQL or wait for a future release:
-
Import-driven state transitions — EP import and result import do not yet drive
ISSUED/IN_USEtransitions. Deferred untiladmin-service/feature/async-import-v2merges (US #475). -
Chip / Chip 2 in the timing feed — Tag pairing is being redesigned in WS3 (US #478). Until then, load the CSV without chip columns and populate chips via the timing system’s own UI.
-
Un-flag (UNFIT → IN_STOCK) — no API. If a number was incorrectly flagged, an admin must reverse it via SQL:
UPDATE race_number SET state='S' WHERE id=<x>;and insert a compensatingrace_number_state_logrow. -
Manual reassignment cascade to future EPs — US #505. Until shipped, replicate changes per-EP as shown above.
-
Pick list endpoint (
GET /api/people/{personId}/race-numbers) — shipped in WS1b for future registration-portal integration; operators typically useGET /api/race-numbers/stock?personId.equals=…which covers the same ground.
16. See Also
-
Number & Tag Runbook — SQL-level procedures used prior to these APIs.
-
Management API Access — authentication deep-dive.
-
Import Operations Guide — EP and result import endpoints.
-
Design journal
2026-03/number-tag-management.adoc— lifecycle design, sessions 1-6.