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_permission row with role = '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 curl and jq. 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 the api_key table

  • Have at least one matching api_key_permission row

  • 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

https://admin-service.idealogic.co.za

Stage

https://admin-service-stage.idealogic.co.za

Dev

https://admin-service-dev.idealogic.co.za

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

GET /api/race-numbers/stock

Paginated; any RaceNumberCriteria filter composes.

Batch return

POST /api/race-numbers/return

Stock-take scan or post-event reconciliation.

Flag unfit

POST /api/race-numbers/flag-unfit

Soft-retirement; keeps person_id.

Dispose

POST /api/race-numbers/dispose

Terminal; requires prior flag-unfit.

Timing feed CSV

GET /api/events/{eventId}/timing-feed

Pre-event export for RaceDay Scoring. External Reference ID follows the event’s preferredTimingIdentifier.

Manual (re)assignment

PUT /api/event-participants/{id}

Update number on the EP DTO; triggers state + last_used stamping.

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

M

MANUFACTURED

Onboarded but tag pairing pending. Not assignable.

S

IN_STOCK

Available for assignment. person_id is NULL.

I

ISSUED

Held by a person for use at an event.

U

IN_USE

Detected by timing at an event.

F

UNFIT_FOR_SERVICE

Retired from auto-assignment. May still be held (person_id set) or in our possession (person_id NULL → disposal candidate).

D

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

state.equals

Exact state code

state.equals=S (IN_STOCK only)

state.in

Comma-separated codes

state.in=S,F (available + flagged)

typeId.equals

NumberType primary key

typeId.equals=78

subTypeId.equals

Subtype primary key

subTypeId.equals=91

personId.equals

Holder (wp_users id)

personId.equals=1234

lastUsed.greaterThan

ISO-8601 Instant

lastUsed.greaterThan=2026-01-01T00:00:00Z

sort

Field + direction

sort=sequence,asc, sort=lastUsed,desc

page, size

Pagination

page=0&size=100

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_USEIN_STOCK, clears person_id, logs RETURNED

  • UNFIT_FOR_SERVICE → state preserved, clears person_id, logs RETURNED with "recommend disposal" prepended to the note

  • IN_STOCK / DESTROYED / MANUFACTURED → no-op (idempotent — no log row written)

  • Unknown id → NOT_FOUND entry in the response

EP linkages (event_participant.number_id) are never cleared — the historical result remains linked.

8.1. Request

{
  "numberIds": [2418, 2419, 2420, 9999999],
  "note": "Post-event 119 return scan"
}

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_UNFIT log 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_SERVICEDESTROYED, clears person_id, logs DISPOSED

  • Any other state → REJECTED_NOT_UNFIT, no state change, no log

  • Unknown id → NOT_FOUND

10.1. Request

{
  "numberIds": [2419, 2421],
  "note": "Physical disposal batch 2026-04-23"
}

10.2. Example

curl -s -X POST -H "X-API-KEY: $API_KEY" -H "Content-Type: application/json" \
  "$BASE_URL/api/race-numbers/dispose" \
  --data '{"numberIds":[2419,2421],"note":"Disposal batch 2026-04-23"}' \
  | jq

Response shape mirrors flag-unfitsuccess/skipped/rejected/notFound + details[].

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:

  1. External Reference ID — the identifier named by Event.preferredTimingIdentifier; see Which identifier lands in External Reference ID

  2. FirstEP.firstName

  3. LastEP.lastName

  4. Gender — single char: M, F, or U

  5. DOByyyy-MM-dd (blank if not set)

  6. Event CategoryEP.category.name

  7. BibEP.number.number (blank if no number assigned)

  8. {Event.customList1Name} — dynamic; only emitted if Event.customList1Name is set; value from EP.custom1.name

  9. {Event.customList2Name} — same rule for slot 2

  10. {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

EPID

EventParticipant.id

We are the system of record for the entry. Stable and internal, but meaningless outside this event.

PERSON_ID

EventParticipant.person.id

Default for new events. Stable across events, so a series can correlate the same rider.

REGISTRATION_ID

EventParticipant.registrationId

Entries were imported from an external registration system and the timing system should round-trip their identifier.

EXTERNAL_PERSON_UID

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 EventParticipant.id unconditionally while the import followed preferredTimingIdentifier. Because event_participant.id and wp_users.ID are independent sequences in the same numeric range, a wrong-mode lookup finds a row — so a result import reported a clean run while attributing every rider’s race to someone else.

Two operational habits survive the fix:

  • Check preferred_timing_identifier against what the file actually contains before handing it to the timing people. Every mode produces a column of plausible-looking integers, so the mode cannot be inferred by eye.

  • Do not change the setting after the first export. The first successful export stamps preferred_timing_identifier_locked_at; changing the identifier afterwards re-keys the timing system’s upsert and duplicates its rows, which cannot be undone from our side.

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 no registrationId.

  • 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: Chip and Chip 2 columns are not emitted yet. The tag pairing mechanism is being redesigned as part of WS3 (see design journal Session 6, 2026-04-23). Load the CSV into RaceDay Scoring with its chip fields blank; chips can be added via the timing system’s own UI, or via a subsequent release that lands the WS3 tag FK.

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:

  1. Validates the transition on the new RaceNumber (IN_STOCK → ISSUED, or carry-over on ISSUED/IN_USE for same person; rejects UNFIT / DESTROYED / MANUFACTURED)

  2. Sets race_number.person_id if moving to ISSUED

  3. Stamps race_number.last_used = now() on every assignment path (see US #476)

  4. Writes a RaceNumberStateLog row with reason ASSIGNED (first-time) or REASSIGNED (swap)

PUT replaces the entire EventParticipant — every field on the body is persisted. Fetch the current EP first, change only the fields you mean to, and send the merged body back. Using PATCH /api/event-participants/{id} (merge-patch) is safer for "change only the number" scenarios.

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
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

400 Bad Request — idnull / idinvalid

Path id must match body id and body id must be non-null.

403 Forbidden

Your key lacks access to the event’s organisation OR the participant’s person.

500 + IllegalStateException — is DESTROYED

Target RaceNumber is terminally retired; pick another.

500 + IllegalStateException — is UNFIT_FOR_SERVICE

Target is flagged; either dispose & replace, or coordinate with an admin to un-flag (no API for un-flag today — SQL).

500 + IllegalStateException — is MANUFACTURED

Onboarding incomplete; tag pairing still pending. Finish onboarding first (out of WS1a/b scope).

500 + IllegalStateException — return it before assigning to person X

Number is ISSUED/IN_USE to a different person. Return it first (POST /return), then assign.

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

200 OK

Success

Any successful GET/POST/PUT/PATCH

400 Bad Request

Client error in the request

Invalid body, id mismatch, missing required field

401 Unauthorized

Missing or invalid API key

Check X-API-KEY header, key is active, permission row exists

403 Forbidden

Authenticated but not permitted

Composite security check failed — no access to event org or participant person

404 Not Found

Resource doesn’t exist

Wrong id, wrong environment

409 Conflict

Optimistic concurrency failure

DTO version is stale — re-fetch and retry

500 Internal Server Error

Unhandled exception

IllegalStateException from the state machine; see message body and server logs

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_USE transitions. Deferred until admin-service/feature/async-import-v2 merges (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 compensating race_number_state_log row.

  • 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 use GET /api/race-numbers/stock?personId.equals=…​ which covers the same ground.

16. See Also