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.

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 IDEventParticipant.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).

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

  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