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— the identifier named byEvent.preferredTimingIdentifier; see Which identifier lands inExternal Reference 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).
11.1. Which identifier lands in External Reference ID
The column carries whichever identifier the event’s preferredTimingIdentifier names. This is the same setting the result import uses to interpret the column when the timing system sends it back, which is what makes the round trip close.
preferredTimingIdentifier |
Column value | Use when |
|---|---|---|
|
|
We are the system of record for the entry. Stable and internal, but meaningless outside this event. |
|
|
Default for new events. Stable across events, so a series can correlate the same rider. |
|
|
Entries were imported from an external registration system and the timing system should round-trip their identifier. |
|
The person’s UID in the event’s source registration system |
Cross-system person correlation. Requires the event to carry a source registration system. |
|
The exported identifier and the event’s setting must agree, and both ends read the same column, so ordinarily they cannot disagree. They could before this was fixed: the feed emitted Two operational habits survive the fix:
|
11.1.1. Refusal when the identifier is incomplete
If the selected mode cannot produce an identifier for every participant, the export is refused with 409 Conflict naming the mode, the number of affected participants, and a sample of their EP ids. It does not emit blanks. A blank identifier is not a partial success — it is a row that will silently fail to match on the way back, which is the failure this endpoint exists to prevent.
Causes, by mode:
-
REGISTRATION_ID— one or more participants have noregistrationId. -
EXTERNAL_PERSON_UID— the event has no source registration system, or a participant has no reference in it.
Resolve by populating the missing identifiers, or by changing the event’s preferredTimingIdentifier to a mode the data supports.
|
WS1b scope limitation: |
11.2. 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.