API reference
The API of a LawFive server, which the web app, the watch apps and the phone apps use. The same reference as OpenAPI 3.1 is openapi.json, for generating code or loading into an API tool.
- Address: everything is under
/v1on the server:https://api.lawfive.app/v1/...for LawFive’s own service, or your own server’s address. - Signing in: apps sign in (or link, for a watch), take the token and send it as
Authorization: Bearer <token>. Browsers use the session cookie and must send anOriginheader matching the server. Everything needs signing in unless it says otherwise; without it the answer is401. - API keys: for scripts and integrations, made in Settings and sent the same way (
Authorization: Bearer lf_...). Each key can use only the collections it was given; each endpoint says what a key needs, and a key without it gets403. See API keys. - Errors: a JSON body with
error, a sentence a person can read (ApiError); the account and linking endpoints have their own shapes (AuthError, OAuthError). Anything unexpected is a500. - Data: JSON. Ids are UUIDs, times are ISO 8601 with a UTC offset, durations are whole seconds. Records are described in the data model (docs/MATCH_CONFIG.md and JSON Schema).
- Older servers: check
capabilitiesinGET /v1/metabefore using address search or weather, which a server can switch off; ignore fields and capabilities you don’t know.
Endpoints
Section titled “Endpoints”| Endpoint | What it does |
|---|---|
GET /health |
Is the server up |
GET /v1/meta |
What this server offers |
POST /v1/auth/sign-up/email |
Make an account |
POST /v1/auth/sign-in/email |
Sign in |
POST /v1/auth/request-password-reset |
Email a password reset link |
POST /v1/auth/reset-password |
Choose a new password |
POST /v1/auth/change-password |
Change the password |
POST /v1/auth/sign-out |
Sign out |
GET /v1/auth/get-session |
Who is signed in |
GET /v1/account |
The signed-in account |
PUT /v1/account/email |
Change the account’s email |
POST /v1/account/delete |
Delete the account |
POST /v1/auth/device/code |
Ask for a linking code |
GET /v1/auth/device |
Look up a code |
POST /v1/auth/device/approve |
Approve a code |
POST /v1/auth/device/deny |
Refuse a code |
POST /v1/auth/device/token |
Poll for the token |
GET /v1/matches |
The user’s matches |
GET /v1/matches/{id} |
One match |
PUT /v1/matches/{id} |
Save a match |
DELETE /v1/matches/{id} |
Delete a match |
The same for match-templates, match-records, venues, competitions, referees, settings |
See Records |
GET /v1/match-records/{id}/track |
A match report’s track |
PUT /v1/match-records/{id}/track |
Upload a match report’s track |
PUT /v1/match-records/{id}/track/pitch |
Mark where the pitch is |
DELETE /v1/match-records/{id}/track/pitch |
Forget where the pitch is |
DELETE /v1/match-records/{id}/track |
Delete a match report’s track |
GET /v1/changes |
Changes since a cursor |
GET /v1/people |
Everyone on the server |
POST /v1/people/{id}/approve |
Approve an account |
POST /v1/people/{id}/decline |
Decline an account |
PUT /v1/people/{id}/admin |
Make or stop being an admin |
PUT /v1/people/{id}/suspended |
Suspend an account, or undo it |
POST /v1/people/{id}/reset-link |
Make a password reset link |
DELETE /v1/people/{id} |
Delete an account |
GET /v1/server-settings |
The server’s settings |
PUT /v1/server-settings |
Change the server’s settings |
POST /v1/setup/check |
Check a new server’s setup code |
POST /v1/setup/finish |
Finish setting the server up |
POST /v1/server-settings/test-email |
Send a test email |
POST /v1/app-sign-in |
A code to sign a phone in |
GET /v1/app-sign-in/{id} |
Whether a phone has used a code |
POST /v1/app-sign-in/check |
Whose a code is |
POST /v1/app-sign-in/redeem |
Swap a code for a session |
GET /v1/invites |
The invitations |
POST /v1/invites |
Invite someone |
DELETE /v1/invites/{id} |
Cancel an invitation |
GET /v1/invite/{token} |
Look up an invitation |
POST /v1/import/matches |
Import matches |
POST /v1/import/file |
Import matches from a file |
GET /v1/calendar |
The calendar feed’s address |
POST /v1/calendar |
Make the calendar feed, or a new address for it |
DELETE /v1/calendar |
Turn the calendar feed off |
GET /v1/calendar/{file} |
The calendar feed |
GET /v1/stats |
Stats on the user’s games |
GET /v1/stats/games.csv |
The games, as a spreadsheet |
GET /v1/devices |
Where the account is signed in |
DELETE /v1/devices/{id} |
Sign a device out |
POST /v1/devices/sign-out-others |
Sign out everywhere else |
PUT /v1/devices/current/push |
Say how to reach this phone |
GET /v1/account/storage |
How much of its storage the account is using |
GET /v1/attachments |
Attachments of a kind of record |
POST /v1/attachments |
Upload an attachment |
PUT /v1/attachments/{id} |
Change an attachment |
DELETE /v1/attachments/{id} |
Delete an attachment |
GET /v1/attachments/{id}/file |
An attachment’s file |
GET /v1/attachments/{id}/thumb |
An attachment’s thumbnail |
GET /v1/export |
Download everything |
POST /v1/restore |
Restore from an export |
GET /v1/matches/{id}/share |
A match’s share link |
POST /v1/matches/{id}/share |
Share a match |
DELETE /v1/matches/{id}/share |
Stop sharing a match |
GET /v1/shared/{token} |
A shared match |
GET /v1/shared/{token}/track |
A shared match’s track |
GET /v1/webhooks |
The account’s webhooks |
POST /v1/webhooks |
Add a webhook |
PUT /v1/webhooks/{id} |
Change a webhook |
DELETE /v1/webhooks/{id} |
Delete a webhook |
GET /v1/webhooks/{id}/deliveries |
A webhook’s deliveries |
POST /v1/webhooks/{id}/test |
Send a test |
GET /v1/api-keys |
The account’s API keys |
POST /v1/api-keys |
Make an API key |
DELETE /v1/api-keys/{id} |
Delete an API key |
GET /v1/watch/matches |
The next matches, for the watch |
POST /v1/watch/records |
Upload a finished match |
POST /v1/watch/tracks |
Upload part of a match’s track |
POST /v1/watch/pitch |
Pitch corners from the watch |
POST /v1/watch/screen |
The match screen chosen on the watch |
POST /v1/watch/fee |
Mark a fee paid from the watch |
GET /v1/geocode |
Find an address |
GET /v1/quiz |
The quiz’s questions |
GET /v1/weather |
Weather for matches in a window |
GET /v1/weather/{matchId} |
One match’s weather |
Server
Section titled “Server”What the server is and offers.
GET /health
Section titled “GET /health”Is the server up. No sign-in needed.
| Answer | Body | When |
|---|---|---|
| 200 | ok (text) |
It is. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/meta
Section titled “GET /v1/meta”What this server offers. The API version and capabilities, the map tiles to use and the data providers to credit. Apps check capabilities before using geocode or weather, which a server can switch off. No sign-in needed.
| Answer | Body | When |
|---|---|---|
| 200 | ServerMeta | The server’s description. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Accounts
Section titled “Accounts”Signing up, in and out (Better Auth’s endpoints). Browsers keep the session in a cookie and must send an Origin header matching the server’s address; apps send no Origin, take the token from the set-auth-token response header and send it as Authorization: Bearer
POST /v1/auth/sign-up/email
Section titled “POST /v1/auth/sign-up/email”Make an account. Signs the new account in. With SIGN_UP=closed only an invitation (or an address in ADMIN_EMAILS) can make one; with SIGN_UP=approval the account is pending until an admin approves it (USR-2 to USR-4). No sign-in needed.
Body: { name: string, email: string, password: string, invite?: string }
| Answer | Body | When |
|---|---|---|
| 200 | { token: string | null, user: User } | Made and signed in. The user’s status is pending when it waits for approval. Header: set-auth-token. |
| 400 | AuthError | Not allowed: an invalid email, a password that’s too short or long, sign-up is closed (SIGN_UP_CLOSED), or the invitation isn’t valid (INVITE_INVALID) or is for another address (INVITE_EMAIL_MISMATCH). |
| 403 | AuthError | A browser request whose Origin isn’t the server’s. |
| 413 | ApiError | The request is too big. |
| 422 | AuthError | An account with this email already exists. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/auth/sign-in/email
Section titled “POST /v1/auth/sign-in/email”Sign in. No sign-in needed.
Body: { email: string, password: string, rememberMe?: boolean }
| Answer | Body | When |
|---|---|---|
| 200 | { redirect: boolean, token: string, user: User } | Signed in. Header: set-auth-token. |
| 401 | AuthError | Wrong email or password. |
| 403 | AuthError | A browser request whose Origin isn’t the server’s, or the account is suspended (ACCOUNT_SUSPENDED, USR-7). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/auth/request-password-reset
Section titled “POST /v1/auth/request-password-reset”Email a password reset link. When the server can send email (the email capability), emails a link to the web app’s page for choosing a new password; it works once, for an hour (USR-7). The answer is the same whether or not there’s an account with the address. Without email, an admin makes a link instead (POST /v1/people/{id}/reset-link). No sign-in needed.
Body: { email: string, redirectTo?: string }
| Answer | Body | When |
|---|---|---|
| 200 | { status: true, message: string } | Emailed, if there’s an account with the address. |
| 400 | AuthError | The server can’t send email (RESET_PASSWORD_DISABLED), or the address isn’t valid. |
| 403 | AuthError | A browser request whose Origin isn’t the server’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/auth/reset-password
Section titled “POST /v1/auth/reset-password”Choose a new password. With the token from a reset link, emailed or made by an admin (USR-7). Devices already signed in stay signed in. No sign-in needed.
Body: { newPassword: string, token: string }
| Answer | Body | When |
|---|---|---|
| 200 | { status: true } | Changed; sign in with it. |
| 400 | AuthError | The link was used, has expired or is mistyped (INVALID_TOKEN), or the password is too short or long. |
| 403 | AuthError | A browser request whose Origin isn’t the server’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/auth/change-password
Section titled “POST /v1/auth/change-password”Change the password. Needs the current password (USR-7).
Body: { currentPassword: string, newPassword: string, revokeOtherSessions?: boolean }
| Answer | Body | When |
|---|---|---|
| 200 | { token: string | null, user: User } | Changed. |
| 400 | AuthError | The current password is wrong (INVALID_PASSWORD), or the new one is too short or long. |
| 401 | AuthError | Not signed in. |
| 403 | AuthError | A browser request whose Origin isn’t the server’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/auth/sign-out
Section titled “POST /v1/auth/sign-out”Sign out. Ends this session. To sign out another device, use DELETE /v1/devices/{id}. No sign-in needed.
Body: {}
| Answer | Body | When |
|---|---|---|
| 200 | { success: true } | Signed out. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/auth/get-session
Section titled “GET /v1/auth/get-session”Who is signed in. No sign-in needed.
| Answer | Body | When |
|---|---|---|
| 200 | { session: Session, user: User } | null | The session and account, or null when not signed in. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/account
Section titled “GET /v1/account”The signed-in account. Its status and whether it’s an admin. The one endpoint a pending account (USR-4) can use; every other gets 403. Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | Account | The account. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/account/email
Section titled “PUT /v1/account/email”Change the account’s email. With the password, to confirm it’s them (USR-7). An address in the server’s ADMIN_EMAILS can’t be changed here, and can’t be changed to. Not with API keys.
Body: EmailChange
| Answer | Body | When |
|---|---|---|
| 200 | Account | Changed. |
| 400 | { error: string, issues?: ValidationIssue[] } | The address isn’t valid, or the password is wrong. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 409 | ApiError | Another account has the address, or ADMIN_EMAILS lists the old or new one. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/account/delete
Section titled “POST /v1/account/delete”Delete the account. With the password, to confirm it’s them (USR-7). Deletes the account and everything in it, signs out every device and stops its API keys and calendar feed. Refused for the only admin while there are other accounts. Not with API keys.
Body: { password: string }
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Deleted. |
| 400 | ApiError | The password is wrong. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 409 | ApiError | It’s the server’s only admin; make someone else an admin first. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Linking a watch
Section titled “Linking a watch”A watch links to an account with a code (RFC 8628): it asks for a code and shows it, someone signed in enters it on the web app’s Link page and approves it, and the watch, polling meanwhile, gets its own token.
POST /v1/auth/device/code
Section titled “POST /v1/auth/device/code”Ask for a linking code. Step 1, from the watch. Codes last 10 minutes. No sign-in needed.
Body: { client_id: “lawfive-garmin”, scope?: string }
| Answer | Body | When |
|---|---|---|
| 200 | DeviceCode | A code to show. |
| 400 | OAuthError | Not a known app (invalid_client). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/auth/device
Section titled “GET /v1/auth/device”Look up a code. Step 2, from the Link page. Signed in, it also claims the code for this session, which approving or refusing it needs. No sign-in needed.
| Parameter | In | Type | Description |
|---|---|---|---|
user_code |
query | string | The code the watch shows. |
| Answer | Body | When |
|---|---|---|
| 200 | { user_code: string, status: “pending” | “approved” | “denied”, client_id?: string, device_name?: string, scope?: string | null } | The code is waiting. |
| 400 | OAuthError | No such code, or it has expired. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/auth/device/approve
Section titled “POST /v1/auth/device/approve”Approve a code. Step 2, after the lookup: links the watch to the signed-in account.
Body: { userCode: string }
| Answer | Body | When |
|---|---|---|
| 200 | { success: true } | Approved; the watch’s next poll gets its token. |
| 400 | OAuthError | No such code, it has expired, or it wasn’t looked up first. |
| 401 | OAuthError | Not signed in. |
| 403 | AuthError | The account is waiting for approval (ACCOUNT_PENDING). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/auth/device/deny
Section titled “POST /v1/auth/device/deny”Refuse a code.
Body: { userCode: string }
| Answer | Body | When |
|---|---|---|
| 200 | { success: true } | Refused; the watch’s next poll gets access_denied. |
| 400 | OAuthError | No such code, it has expired, or it wasn’t looked up first. |
| 401 | OAuthError | Not signed in. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/auth/device/token
Section titled “POST /v1/auth/device/token”Poll for the token. Step 3, from the watch, every interval seconds until it’s approved, refused or expired. No sign-in needed.
Body: { grant_type: “urn:ietf:params:oauth:grant-type:device_code”, device_code: string, client_id: string }
| Answer | Body | When |
|---|---|---|
| 200 | DeviceToken | Approved: the watch’s token. |
| 400 | OAuthError | Not yet (authorization_pending), too often (slow_down), refused (access_denied) or expired (expired_token). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Records
Section titled “Records”The user’s records, one collection per kind. Ids are UUIDs made by the client, so a record created offline keeps its id. Every change raises version; an update sends the version it last saw and gets 409 if the record changed in the meantime. Deleted records are kept as markers so other devices learn of the deletion.
| Collection | Record | Saved as | Notes |
|---|---|---|---|
/v1/matches |
Match | MatchInput | |
/v1/match-templates |
MatchTemplate | MatchTemplateInput | |
/v1/match-records |
MatchRecord | MatchRecordInput | Watches upload theirs through POST /v1/watch/records instead. |
/v1/venues |
Venue | VenueInput | |
/v1/competitions |
Competition | CompetitionInput | |
/v1/referees |
Referee | RefereeInput | |
/v1/settings |
UserSettings | UserSettingsInput | There’s one per user: create it once, then update it. |
Each has the same four endpoints, shown here for matches.
GET /v1/matches
Section titled “GET /v1/matches”The user’s matches. Every match that isn’t deleted, oldest change first. API keys: need matches:read.
| Answer | Body | When |
|---|---|---|
| 200 | { items: Match[] } | The matches. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without matches:read; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/matches/{id}
Section titled “GET /v1/matches/{id}”One match. Deleted ones too, with deletedAt set. API keys: need matches:read.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | Match | The match. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without matches:read; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/matches/{id}
Section titled “PUT /v1/matches/{id}”Save a match. Creates the match (send no version) or updates it (send the version you last saw). Saving a deleted record brings it back. API keys: need matches:write.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
Body: MatchInput
| Answer | Body | When |
|---|---|---|
| 200 | Match | Updated; version is raised. |
| 201 | Match | Created, at version 1. |
| 400 | { error: string, issues?: ValidationIssue[] } | The id isn’t a UUID, the body’s id differs from the URL’s, or the body doesn’t match the schema (issues lists the problems). |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without matches:write; or the account is waiting for approval (USR-4). |
| 409 | { error: string, current?: Match } | It changed since the version sent (current is the record as it is now), the id belongs to another kind of record, or it would duplicate another (a match’s external id, a second settings record). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/matches/{id}
Section titled “DELETE /v1/matches/{id}”Delete a match. Marks it deleted; it stays as a marker so other devices hear of it. Deleting it again changes nothing. API keys: need matches:write.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
version (optional) |
query | integer | The version last seen; when given, the delete fails if it has changed since. |
| Answer | Body | When |
|---|---|---|
| 200 | Match | Deleted: the record with deletedAt. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without matches:write; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 409 | { error: string, current: Match } | It changed since the version sent. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Tracks
Section titled “Tracks”GPS and heart rate for a match report (TRK-1 to TRK-3), in LawFive’s own format whatever recorded them: convert from the source (RefSix, an activity file, a phone’s health store) before uploading. Not part of sync, as tracks are big; fetch one when it’s shown. The figures (distance, speeds, sprints, heart rate and zones) are worked out by the server each time.
GET /v1/match-records/{id}/track
Section titled “GET /v1/match-records/{id}/track”A match report’s track. With its figures, over the periods played when the match report has their start times. API keys: need match-records:read.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | { track: MatchTrack, summary: TrackSummary, pitch?: PitchOutline, heat?: { cols: integer, rows: integer, lengthM: number, widthM: number, total: number[], periods: ({ period: integer, cells: number[] })[] }, updatedAt: Timestamp } | The track and its figures. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without match-records:read; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No track for this match report, or no such match report. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/match-records/{id}/track
Section titled “PUT /v1/match-records/{id}/track”Upload a match report’s track. Replaces any track the match report had. Up to 43,200 samples (12 hours at one a second) in each series. API keys: need match-records:write.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
Body: MatchTrack
| Answer | Body | When |
|---|---|---|
| 200 | { summary: TrackSummary, updatedAt: Timestamp } | Replaced the track it had. |
| 201 | { summary: TrackSummary, updatedAt: Timestamp } | Stored. |
| 400 | ValidationError | The body doesn’t match the schema (issues lists the problems). |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without match-records:write; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No such match report: save it first. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/match-records/{id}/track/pitch
Section titled “PUT /v1/match-records/{id}/track/pitch”Mark where the pitch is. The two corners of one touchline and the centre spot, as the referee placed them (TRK-9); kept with the track, marked by: referee. Heatmaps and Stats use it. API keys: need match-records:write.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
Body: { corners: [Position, Position], centre: Position }
| Answer | Body | When |
|---|---|---|
| 200 | { pitch: PitchOutline, updatedAt: Timestamp } | Kept. |
| 400 | ValidationError | The body doesn’t match the schema. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without match-records:write; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No track for this match report. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/match-records/{id}/track/pitch
Section titled “DELETE /v1/match-records/{id}/track/pitch”Forget where the pitch is. Back to the pitch found from the track (TRK-9). API keys: need match-records:write.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | { pitch?: PitchOutline, updatedAt: Timestamp } | Forgotten; pitch is the one found from the track, when there’s enough GPS. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without match-records:write; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No track for this match report. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/match-records/{id}/track
Section titled “DELETE /v1/match-records/{id}/track”Delete a match report’s track. The match report stays. API keys: need match-records:write.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Deleted. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without match-records:write; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No track for this match report. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Everything that changed since a device last looked.
GET /v1/changes
Section titled “GET /v1/changes”Changes since a cursor. Every record written after the cursor, deletions included, oldest first, across all collections (for an API key, the ones it can read). Start with since=0, keep the cursor, and ask again while more is true. Changes made at the same time are never skipped. API keys: any read scope; only those collections are included.
| Parameter | In | Type | Description |
|---|---|---|---|
since (optional) |
query | integer | The cursor from the last page; 0 (the default) for everything. |
limit (optional) |
query | integer | Changes per page: 500 by default, at most 1000. |
| Answer | Body | When |
|---|---|---|
| 200 | ChangesPage | A page of changes. |
| 400 | ApiError | since or limit isn’t a whole number. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without *:read; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
People
Section titled “People”Admins (USR-1) see everyone on the server, approve or decline new accounts (USR-4), make other admins, and make password reset links, suspend and delete accounts, and change who can sign up (USR-7). Everyone else gets 403.
GET /v1/people
Section titled “GET /v1/people”Everyone on the server. Waiting for approval first, then everyone else by name. Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | { people: Person[] } | The accounts. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/people/{id}/approve
Section titled “POST /v1/people/{id}/approve”Approve an account. It can use LawFive straight away; they’re emailed when the server can send email. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | string |
Body: {}
| Answer | Body | When |
|---|---|---|
| 200 | Person | Approved. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 404 | ApiError | No such account. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/people/{id}/decline
Section titled “POST /v1/people/{id}/decline”Decline an account. Deletes an account that’s waiting for approval. Approved accounts can’t be declined. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | string |
Body: {}
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Declined and deleted. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 404 | ApiError | No such account. |
| 409 | ApiError | The account isn’t waiting for approval. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/people/{id}/admin
Section titled “PUT /v1/people/{id}/admin”Make or stop being an admin. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | string |
Body: { admin: boolean }
| Answer | Body | When |
|---|---|---|
| 200 | Person | Changed. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 404 | ApiError | No such account. |
| 409 | ApiError | Your own account, an account waiting for approval, or one that ADMIN_EMAILS makes an admin. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/people/{id}/suspended
Section titled “PUT /v1/people/{id}/suspended”Suspend an account, or undo it. A suspended account can’t sign in, and is signed out everywhere (linked watches need linking again afterwards); its API keys and calendar feed stop working. Its data is kept (USR-7). Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | string |
Body: { suspended: boolean }
| Answer | Body | When |
|---|---|---|
| 200 | Person | Changed. |
| 400 | ApiError | The body isn’t { suspended: true } or { suspended: false }. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 404 | ApiError | No such account. |
| 409 | ApiError | Your own account, an account waiting for approval, or one that ADMIN_EMAILS makes an admin. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/people/{id}/reset-link
Section titled “POST /v1/people/{id}/reset-link”Make a password reset link. For someone who forgot their password, on any server: the admin sends it to them. It works once, for 24 hours (USR-7). Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | string |
Body: {}
| Answer | Body | When |
|---|---|---|
| 201 | ResetLink | Made. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 404 | ApiError | No such account. |
| 409 | ApiError | Your own account (change it in Settings), or one waiting for approval. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/people/{id}
Section titled “DELETE /v1/people/{id}”Delete an account. Deletes the account and everything in it (USR-7). It can’t be undone. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | string |
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Deleted. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 404 | ApiError | No such account. |
| 409 | ApiError | Your own account (delete it in Settings), or one that ADMIN_EMAILS makes an admin. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/server-settings
Section titled “GET /v1/server-settings”The server’s settings. Each setting an admin can change (ADM-1, ADM-2), where its value comes from, and the value (not for secrets). Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | { settings: ServerSettingView[] } | The settings. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/server-settings
Section titled “PUT /v1/server-settings”Change the server’s settings. Sets or clears the settings sent (ADM-1); one the environment sets can’t be changed, and if any can’t be, none are. Takes effect straight away. GET /v1/meta says what’s offered. Not with API keys.
Body: ServerSettings
| Answer | Body | When |
|---|---|---|
| 200 | { settings: ServerSettingView[] } | Changed: the settings as they are now. |
| 400 | { error: string, issues?: ValidationIssue[] } | The body doesn’t match, a value isn’t one the setting takes, or the environment sets it. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/setup/check
Section titled “POST /v1/setup/check”Check a new server’s setup code. While nobody has an account, whether this is the code in the server’s logs (ADM-5). The first account is made through sign-up, with the code as setupCode. No sign-in needed.
Body: { code: string }
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | It’s the code. |
| 400 | ApiError | It isn’t. |
| 404 | ApiError | The server is already set up, or doesn’t need a code. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/setup/finish
Section titled “POST /v1/setup/finish”Finish setting the server up. The first account finished or skipped the setup wizard, so it doesn’t open again (ADM-5). Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Done. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/server-settings/test-email
Section titled “POST /v1/server-settings/test-email”Send a test email. To the signed-in admin, through the email settings in use (ADM-4). Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | { to: string } | Sent. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 409 | ApiError | Email isn’t set up. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
| 502 | ApiError | The SMTP server didn’t take it; the error says why. |
App sign-in
Section titled “App sign-in”Signing in on a phone with a code the web app shows, as a QR code or in letters (APP-13), instead of typing a password.
POST /v1/app-sign-in
Section titled “POST /v1/app-sign-in”A code to sign a phone in. It works once, for two minutes. Not with API keys.
| Answer | Body | When |
|---|---|---|
| 201 | AppSignIn | Made. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/app-sign-in/{id}
Section titled “GET /v1/app-sign-in/{id}”Whether a phone has used a code. So the web app can say when the phone is signed in. Expired codes go when a new one is made, and then this answers 404. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | AppSignInStatus | How it stands. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No such code, or not this account’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/app-sign-in/check
Section titled “POST /v1/app-sign-in/check”Whose a code is. For the phone to ask before signing in. It doesn’t use the code up. No sign-in needed.
Body: AppSignInCode
| Answer | Body | When |
|---|---|---|
| 200 | AppSignInCheck | It can be used. |
| 404 | ApiError | No such code. |
| 410 | ApiError | It was used, or it has expired. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/app-sign-in/redeem
Section titled “POST /v1/app-sign-in/redeem”Swap a code for a session. The session belongs to the device asking, and shows under Devices. No sign-in needed.
Body: AppSignInCode
| Answer | Body | When |
|---|---|---|
| 200 | AppSignInResult | Signed in. |
| 403 | ApiError | The account is suspended. |
| 404 | ApiError | No such code. |
| 410 | ApiError | It was used, or it has expired. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Invitations
Section titled “Invitations”Links that let someone make an account even when sign-up is closed (USR-3). Admins make and cancel them; the link opens the web app’s invitation page, which looks the invitation up and signs up with it.
GET /v1/invites
Section titled “GET /v1/invites”The invitations. Newest first, including used and expired ones from the last 90 days. Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | { invites: Invite[] } | The invitations. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/invites
Section titled “POST /v1/invites”Invite someone. With send, it’s emailed to them too, when the server can send email. Not with API keys.
Body: NewInvite
| Answer | Body | When |
|---|---|---|
| 201 | { invite: Invite, emailed: boolean } | Made. |
| 400 | { error: string, issues?: ValidationIssue[] } | The body doesn’t match. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 409 | ApiError | There’s already an account with this email. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/invites/{id}
Section titled “DELETE /v1/invites/{id}”Cancel an invitation. Its link stops working straight away. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Cancelled. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | Not an admin. |
| 404 | ApiError | No such invitation, or it was used. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/invite/{token}
Section titled “GET /v1/invite/{token}”Look up an invitation. For the invitation page, before signing up with it. No sign-in needed.
| Parameter | In | Type | Description |
|---|---|---|---|
token |
path | string |
| Answer | Body | When |
|---|---|---|
| 200 | InviteLookup | It can be used. |
| 404 | ApiError | No such invitation: the link is mistyped, or it was cancelled. |
| 410 | ApiError | It was used, or it has expired. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Import
Section titled “Import”Adding and updating matches from a spreadsheet, CSV or JSON file (IMP-1 to IMP-8). Apps read the file into rows; the server matches each row to a match (by LawFive id, else match number), finds or adds the venues, competitions and referees it names, and saves only what the row fills in. Check first with dryRun.
POST /v1/import/matches
Section titled “POST /v1/import/matches”Import matches. Each row adds a match or updates one: the row’s ID column (a LawFive match id) wins, else its match number (any source). Only the columns a row fills in change an existing match. Venues, competitions and referees are found by name, ignoring case, and added when there’s none (an API key then needs write access to those too). Rows with problems are skipped and the rest are saved; nothing is saved on a dry run.
The columns, by their headings (any case or spacing, so home_colour and homeColour work too, as do common other headings like Field for Pitch):
| Column | JSON | What it’s for | Example | Needed |
|---|---|---|---|---|
| ID | id |
LawFive’s own ID for a match, as in a LawFive export. Leave it empty for a new match; with it, the row updates that match. | 5c1e2a9b-… | Optional |
| Match number | matchNumber |
The league’s or assignor’s number for the match. Importing a file again with the same number updates the match instead of adding a copy. | BYL-1142 | Optional |
| Status | status |
scheduled, played, abandoned, canceled or postponed. A new match is scheduled when it’s empty. | scheduled | Optional |
| Date | date |
The day of the match: 2026-11-07, or the way your computer writes dates (11/7/2026 in the US, 7/11/2026 in most other places). | 2026-11-07 | New matches* |
| Time | time |
The kickoff time: 14:30 or 2:30 PM. A new match with a date needs one. | 2:30 PM | Optional |
| Kickoff | kickoff |
The date and time together, instead of Date and Time. Add a UTC offset (2026-11-07T14:30-08:00) to fix the moment exactly. | 2026-11-07 14:30 | New matches* |
| Time zone | timeZone |
Where the kickoff time is, by its name in the time zone database. Empty: the match’s own time zone, or your computer’s. | America/Los_Angeles | Optional |
| Home | home |
The home team’s name. | Strikers U12 | New matches* |
| Home short name | homeShortName |
Up to 4 letters for the home team on the watch. Empty: the watch uses the first three letters of the name. | STR | Optional |
| Home color | homeColour |
The home team’s color on the watch: black, white, red, orange, yellow, green, blue or purple, or a hex code like #D71920. | red | Optional |
| Away | away |
The away team’s name. | Rovers U12 | New matches* |
| Away short name | awayShortName |
Up to 4 letters for the away team on the watch. Empty: the watch uses the first three letters of the name. | ROV | Optional |
| Away color | awayColour |
The away team’s color on the watch, written like Home color. | #0057B8 | Optional |
| Competition | competition |
The league, cup or tournament, by name. One you don’t have yet is added, as a league. | Bay Youth League U12 Boys | Optional |
| Venue | venue |
Where it’s played, by name. One you don’t have yet is added; give it an address and a place on the map in Venues. | Twin Creeks Sports Complex | Optional |
| Pitch | pitch |
The pitch at the venue. It’s added to the venue if the venue doesn’t have it yet. | Field 3 | Optional |
| Match name | matchName |
A name for the match itself, shown with the competition: a round, a final, a friendly’s occasion. | Semi-final | Optional |
| Age group | ageGroup |
As the league writes it: U12, U19, Adult, O35. | U12 | Optional |
| Gender | gender |
Who plays: male, female or co-ed (boys, girls, men, women and mixed work too). | female | Optional |
| Role | role |
Your role: referee, AR1, AR2 or fourth official. Empty: taken from a crew column with your name in it, if there is one. | AR1 | Optional |
| Referee | referee |
The referee’s name, or one of their other names. Your own name makes it your role; someone you haven’t saved yet is added to Referees. TBD or empty: nobody yet. | Priya Natarajan | Optional |
| AR1 | ar1 |
The first assistant referee’s name, written like Referee. | Sam Rivera | Optional |
| AR2 | ar2 |
The second assistant referee’s name, written like Referee. | Jordan Lee | Optional |
| Fourth official | fourthOfficial |
The fourth official’s name, written like Referee. | Sam Rivera | Optional |
| Template | template |
One of your match templates, by name, for its format (periods, their length, breaks). It must already exist. Empty for a new match: the competition’s template, or 2 × 45 minutes. | U12 league: 2×30 | Optional |
| Periods | periods |
How many periods: 2 for halves, 4 for quarters. Overrides the template’s. | 2 | Optional |
| Period length (min) | periodLength |
Minutes in each period. Overrides the template’s. | 30 | Optional |
| Half-time (min) | halfTime |
Minutes of break between periods. Overrides the template’s. | 5 | Optional |
| Team size | teamSize |
Players a side on the field, 1 to 11. Overrides the template’s. | 9 | Optional |
| Fee | fee |
What you’re paid for the match, as a number. | 40.50 | Optional |
| Currency | currency |
The fee’s currency, as a three-letter code (USD, GBP, EUR). Empty: your currency in Settings. | USD | Optional |
| Paid | paid |
Whether you’ve been paid the fee: yes or no. | no | Optional |
| Tags | tags |
Labels to find and filter matches by, separated by commas. | league, needs AR | Optional |
| Notes | notes |
Anything to remember for the match. | Bring the spare flags. | Optional |
| Outlook | outlook |
What to expect: how competitive it’ll be, the teams’ strengths, history between them. | Top of the table meets second. | Optional |
* A new match needs a date (Date and Time, or Kickoff) or its teams; everything else is optional, and an empty value never changes a match. API keys: need matches:write.
Body: MatchImport
| Answer | Body | When |
|---|---|---|
| 200 | MatchImportResult | What happened to each row (or would, on a dry run). |
| 400 | { error: string, issues?: ValidationIssue[] } | The body doesn’t match: no rows, more than 1000, or not rows of values. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without matches:write; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/import/file
Section titled “POST /v1/import/file”Import matches from a file. The same import as POST /v1/import/matches, from a file the server reads: CSV (commas, semicolons or tabs), Excel (.xlsx, its Matches sheet or else the first) or JSON (a list of objects, or an object with a matches list). The web app and phone apps use this; send it with dryRun first to check every row, then again without to save. API keys: need matches:write.
Body: multipart/form-data: the fields of MatchImportFileFields, and the files file (required: the file, at most 5 MB and 1000 rows)
| Answer | Body | When |
|---|---|---|
| 200 | MatchImportFileResult | What happened to each row (or would, on a dry run), and where each is in the file. |
| 400 | { error: string, issues?: ValidationIssue[] } | The file is missing, too big, or can’t be read; error says why, in words to show the person who chose it. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without matches:write; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Calendar
Section titled “Calendar”A private calendar feed of the user’s matches (CAL-1 to CAL-5), for calendar apps to subscribe to.
GET /v1/calendar
Section titled “GET /v1/calendar”The calendar feed’s address. Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | CalendarFeed | The feed. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | There’s no feed; make one with POST. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/calendar
Section titled “POST /v1/calendar”Make the calendar feed, or a new address for it. A new address replaces the old one, which stops working (calendars subscribed to it stop updating). Not with API keys.
Body: {}
| Answer | Body | When |
|---|---|---|
| 201 | CalendarFeed | Made. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/calendar
Section titled “DELETE /v1/calendar”Turn the calendar feed off. Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Its address stops working. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | There’s no feed. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/calendar/{file}
Section titled “GET /v1/calendar/{file}”The calendar feed. What calendar apps fetch: the user’s matches as events (CAL-2 to CAL-4), past and future, cancelled ones marked so. The address is the secret; there’s no other sign-in. No sign-in needed.
| Parameter | In | Type | Description |
|---|---|---|---|
file |
path | string | The feed’s token and .ics. |
| Answer | Body | When |
|---|---|---|
| 200 | iCalendar | The calendar (text/calendar). |
| 403 | ApiError | The account is suspended (USR-7). |
| 404 | ApiError | No feed has this address: it was replaced or turned off. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Reports on the user’s games (STAT-1 to STAT-4), worked out by the server so every app shows the same.
GET /v1/stats
Section titled “GET /v1/stats”Stats on the user’s games. Games are matches played or abandoned. Every figure follows the filters; options lists what the filters can choose from. Not open to API keys yet: the answer includes referee and venue names. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
from (optional) |
query | LocalDate | The first day, in each match’s own time zone; none for the first game. |
to (optional) |
query | LocalDate | The last day, inclusive; none for today and on. |
gender (optional) |
query | Gender | |
competitionId (optional) |
query | Id | |
role (optional) |
query | “referee” | “assistant” | “fourth” | Your role: referee, assistant (AR1 or AR2) or fourth. |
tag (optional) |
query | string | Games with this tag (ignoring case). |
venueId (optional) |
query | Id |
| Answer | Body | When |
|---|---|---|
| 200 | Stats | The stats. |
| 400 | ApiError | A filter isn’t valid: from or to isn’t a date, an unknown gender or role, or an id isn’t a UUID. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/stats/games.csv
Section titled “GET /v1/stats/games.csv”The games, as a spreadsheet. The games matching the filters (and, optionally, one slice of a chart), oldest first, as CSV with a header row: date, kickoff, competition, home, away, score, role, venue, pitch, age group, gender, distance (m), average heart rate, kickoff temperature (°C), rain (mm), cautions, send-offs, added time (s), fee, currency and paid (STAT-12). Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
from (optional) |
query | LocalDate | The first day, in each match’s own time zone; none for the first game. |
to (optional) |
query | LocalDate | The last day, inclusive; none for today and on. |
gender (optional) |
query | Gender | |
competitionId (optional) |
query | Id | |
role (optional) |
query | “referee” | “assistant” | “fourth” | Your role: referee, assistant (AR1 or AR2) or fourth. |
tag (optional) |
query | string | Games with this tag (ignoring case). |
venueId (optional) |
query | Id | |
pitchId (optional) |
query | Id | “none” | At this pitch of the venue, or none for games at the venue without a pitch. |
refereeId (optional) |
query | Id | With this saved referee on the crew. |
refereeName (optional) |
query | string | With this name typed in on the crew (ignoring case). |
ageGroup (optional) |
query | string | |
month (optional) |
query | string | Kicking off in this month, 2026-04, locally. |
| Answer | Body | When |
|---|---|---|
| 200 | CSV | The games, as a download (Content-Disposition: attachment). |
| 400 | ApiError | A filter isn’t valid. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Devices
Section titled “Devices”Where the account is signed in.
GET /v1/devices
Section titled “GET /v1/devices”Where the account is signed in. Browsers and linked watches, most recently active first. Apps name themselves with an X-LawFive-Device header: garmin, and from the watch app’s later versions what it runs on, garmin; model=006-B3703-00; firmware=12.30, which names it (Garmin Venu 2), and its own version, app=2026.10.30 (VER-3). Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | { devices: Device[] } | The devices. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/devices/{id}
Section titled “DELETE /v1/devices/{id}”Sign a device out. Unlinks a watch or signs a browser out; its next request gets 401. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | string |
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Signed out. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/devices/sign-out-others
Section titled “POST /v1/devices/sign-out-others”Sign out everywhere else. Ends the account’s other sessions, keeping the one asking (SEC-7): other browsers only, or every other device, which unlinks watches and signs phones out. Not with API keys.
Body: SignOutOthers
| Answer | Body | When |
|---|---|---|
| 200 | { signedOut: integer } | How many were signed out. |
| 400 | ApiError | Not browsers or all. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/devices/current/push
Section titled “PUT /v1/devices/current/push”Say how to reach this phone. Keeps the handle the push relay gave this phone with its session, so signing out removes it (PUSH-4). Needs an X-LawFive-Device header. Not with API keys. Only when GET /v1/meta lists push. Not with API keys.
Body: PushHandle
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Kept. |
| 400 | ApiError | The body doesn’t match, or there’s no device header. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/account/storage
Section titled “GET /v1/account/storage”How much of its storage the account is using. Photos and tracks, and the limit if this server has one (SEC-6). Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | AccountStorage | The figures. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Attachments
Section titled “Attachments”Photos on matches, venues and referees (FILE-1 to FILE-5), kept by the server on its disk or in S3-compatible storage. Upload the file as multipart/form-data, ideally already made smaller (the web app sends at most 2000 pixels on the longest side, as a JPEG without its location and camera details) with a thumbnail; the server keeps what it’s sent. The files are private: fetch them with the same sign-in. Deleting the record deletes its attachments. Only a signed-in session can use them for now.
GET /v1/attachments
Section titled “GET /v1/attachments”Attachments of a kind of record. In order (by record, then position). With recordId, just that record’s; with first, only each record’s first (a referee’s picture). Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
recordType |
query | AttachmentRecordType | The kinds of record that can have attachments. |
recordId (optional) |
query | Id | |
first (optional) |
query | “true” | Only each record’s first attachment. |
| Answer | Body | When |
|---|---|---|
| 200 | { attachments: Attachment[] } | The attachments. |
| 400 | ApiError | recordType or recordId isn’t valid. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/attachments
Section titled “POST /v1/attachments”Upload an attachment. Added after the record’s others. At most 30 on a record. Not with API keys.
Body: multipart/form-data: the fields of AttachmentUpload, and the files file (required: the photo, a JPEG, PNG or WebP of at most 10 MB), thumb (optional: a small version, at most 1 MB, for lists and galleries)
| Answer | Body | When |
|---|---|---|
| 201 | Attachment | Uploaded. |
| 400 | { error: string, issues?: ValidationIssue[] } | With storage ({ usedBytes, limitBytes }) when it doesn’t fit the account’s room (FILE-7). A field isn’t valid, the file is missing, too big or not a JPEG, PNG or WebP, or the record has 30 already. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No such record: save it first. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/attachments/{id}
Section titled “PUT /v1/attachments/{id}”Change an attachment. Its caption, or its place among the record’s attachments (moving a referee’s photo to 0 makes it their picture). Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
Body: AttachmentChange
| Answer | Body | When |
|---|---|---|
| 200 | Attachment | Changed. |
| 400 | { error: string, issues?: ValidationIssue[] } | The body doesn’t match. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/attachments/{id}
Section titled “DELETE /v1/attachments/{id}”Delete an attachment. With its files, for good. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Deleted. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/attachments/{id}/file
Section titled “GET /v1/attachments/{id}/file”An attachment’s file. As uploaded. Files never change, so they can be kept (Cache-Control: private, immutable). Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | An image | The image. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/attachments/{id}/thumb
Section titled “GET /v1/attachments/{id}/thumb”An attachment’s thumbnail. The small version, or the file itself when none was uploaded. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | An image | The image. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Export
Section titled “Export”Everything in the account as one zip (DATA-2), and restoring one into an account (DATA-4), for backups and moving between servers. The zip has lawfive.json (what it is), README.md, records/
GET /v1/export
Section titled “GET /v1/export”Download everything. Every record, track and photo in the account, as a zip (Content-Disposition: attachment). It’s made as it’s sent, so it starts straight away however big it is. Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | A zip file | The zip. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/restore
Section titled “POST /v1/restore”Restore from an export. Merges an export into the account: records keep their ids, anything missing is added, and where the account has a record too, whichever was changed last is kept. Tracks are added to match reports without one; photos the account doesn’t have are added. At most 2 GB. Not with API keys.
Body: A zip made by GET /v1/export (application/zip)
| Answer | Body | When |
|---|---|---|
| 200 | RestoreResult | Restored. |
| 400 | ApiError | It isn’t a LawFive export, or it’s too big. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Sharing
Section titled “Sharing”A match’s share link (DATA-7): an address nobody can guess that shows the match, its log and its track to anyone who has it, without signing in. Never the notes, fee, crew, contacts, player names or photos.
GET /v1/matches/{id}/share
Section titled “GET /v1/matches/{id}/share”A match’s share link. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | MatchShare | Its link. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | It isn’t shared, or there’s no such match. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/matches/{id}/share
Section titled “POST /v1/matches/{id}/share”Share a match. Makes its link, or a new one in place of the old, which stops working. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 201 | MatchShare | Its link. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No such match. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/matches/{id}/share
Section titled “DELETE /v1/matches/{id}/share”Stop sharing a match. Its link stops working. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Not shared any more. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | It isn’t shared. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/shared/{token}
Section titled “GET /v1/shared/{token}”A shared match. What the share link’s page shows. The token is the secret; there’s no other sign-in. Answers carry X-Robots-Tag: noindex. No sign-in needed.
| Parameter | In | Type | Description |
|---|---|---|---|
token |
path | string | The token in the share link. |
| Answer | Body | When |
|---|---|---|
| 200 | SharedMatch | The match. |
| 403 | ApiError | The account is suspended (USR-7). |
| 404 | ApiError | No match has this link: it was replaced or turned off, or the match was deleted. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/shared/{token}/track
Section titled “GET /v1/shared/{token}/track”A shared match’s track. With its figures, as the owner sees them. No sign-in needed.
| Parameter | In | Type | Description |
|---|---|---|---|
token |
path | string | The token in the share link. |
| Answer | Body | When |
|---|---|---|
| 200 | { track: MatchTrack, summary: TrackSummary, pitch?: PitchOutline, heat?: { cols: integer, rows: integer, lengthM: number, widthM: number, total: number[], periods: ({ period: integer, cells: number[] })[] }, updatedAt: Timestamp } | The track and its figures. |
| 403 | ApiError | The account is suspended (USR-7). |
| 404 | ApiError | No match has this link, or it has no track. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Webhooks
Section titled “Webhooks”URLs that hear about changes to the account’s records (HOOK-1 to HOOK-5): matches, match reports, venues, referees, competitions and templates, however they were made (the web app, the API, a watch, an import). Each delivery is a signed JSON POST (WebhookPayload), retried up to five times over about three hours when it doesn’t get a 2xx answer. Only a signed-in session can manage them.
GET /v1/webhooks
Section titled “GET /v1/webhooks”The account’s webhooks. Newest first, each with its latest delivery. Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | { webhooks: Webhook[] } | The webhooks. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/webhooks
Section titled “POST /v1/webhooks”Add a webhook. At most 10 per account. The answer has the secret deliveries are signed with, which isn’t shown again. Not with API keys.
Body: WebhookInput
| Answer | Body | When |
|---|---|---|
| 201 | CreatedWebhook | Added. |
| 400 | { error: string, issues?: ValidationIssue[] } | The body doesn’t match, the address isn’t allowed (a private network, unless the server allows it), or the account already has 10. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
PUT /v1/webhooks/{id}
Section titled “PUT /v1/webhooks/{id}”Change a webhook. Its address, topics, or pausing it (active: false). Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
Body: WebhookInput
| Answer | Body | When |
|---|---|---|
| 200 | Webhook | Changed. |
| 400 | { error: string, issues?: ValidationIssue[] } | The body doesn’t match, or the address isn’t allowed. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/webhooks/{id}
Section titled “DELETE /v1/webhooks/{id}”Delete a webhook. With its deliveries; nothing more is sent. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Deleted. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/webhooks/{id}/deliveries
Section titled “GET /v1/webhooks/{id}/deliveries”A webhook’s deliveries. The latest 50, newest first. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | { deliveries: WebhookDelivery[] } | The deliveries. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/webhooks/{id}/test
Section titled “POST /v1/webhooks/{id}/test”Send a test. Sends an example delivery now, once, and says how it went. The example is the account’s latest record of the topic, or a made-up one; test is true in it. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
Body: { topic?: WebhookTopic }
| Answer | Body | When |
|---|---|---|
| 200 | { delivery: WebhookDelivery, payload: WebhookPayload, durationMs: integer } | Sent (whatever the answer). |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
API keys
Section titled “API keys”Keys for scripts and integrations (KEY-1 to KEY-4), made in the web app’s Settings. A key is sent like a session token, Authorization: Bearer
GET /v1/api-keys
Section titled “GET /v1/api-keys”The account’s API keys. Newest first. The keys themselves aren’t kept, only their first characters. Not with API keys.
| Answer | Body | When |
|---|---|---|
| 200 | { keys: ApiKey[] } | The keys. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/api-keys
Section titled “POST /v1/api-keys”Make an API key. At most 20 per account. Not with API keys.
Body: NewApiKey
| Answer | Body | When |
|---|---|---|
| 201 | CreatedApiKey | Made: the answer has the key, which isn’t shown again. |
| 400 | { error: string, issues?: ValidationIssue[] } | The body doesn’t match, or the account already has 20 keys. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
DELETE /v1/api-keys/{id}
Section titled “DELETE /v1/api-keys/{id}”Delete an API key. It stops working straight away. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
id |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true } | Deleted. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Not found, or not this user’s. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
What a Garmin watch uses, shaped for its little memory: matches in the form it starts one from, and finished matches as it saved them.
GET /v1/watch/matches
Section titled “GET /v1/watch/matches”The next matches, for the watch. Scheduled matches from three hours ago on, soonest first, so one that’s about to start or running late is still there. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
limit (optional) |
query | integer | At most 10 (the default). |
| Answer | Body | When |
|---|---|---|
| 200 | { matches: WatchMatch[], serverVersion?: string, screen?: WatchScreenChange } | The matches. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/watch/records
Section titled “POST /v1/watch/records”Upload a finished match. Stored as a match record, and its planned match marked played. A quick match (no matchId) gets a match made for it from what the watch knew. Sending the same match again updates it. Not with API keys.
Body: WatchUpload
| Answer | Body | When |
|---|---|---|
| 200 | WatchUploadResult | Updated: this match was sent before. |
| 201 | WatchUploadResult | Stored. |
| 400 | ValidationError | The body doesn’t match. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 409 | ApiError | It would duplicate another record. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/watch/tracks
Section titled “POST /v1/watch/tracks”Upload part of a match’s track. The watch sends the track it recorded (TRK-7) a chunk at a time, after the match. Once every chunk and the match are in, the chunks become the match report’s track, unless it has one already (from a file, say), which is kept. Sending a chunk again replaces it. Not with API keys.
Body: WatchTrackChunk
| Answer | Body | When |
|---|---|---|
| 200 | WatchTrackResult | Received. |
| 400 | ValidationError | The body doesn’t match, or the samples can’t be read. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/watch/pitch
Section titled “POST /v1/watch/pitch”Pitch corners from the watch. Corners walked on the watch (TRK-13), sent at its next sync (SYNC-12). They’re kept with the match, replacing any it had, and with its venue’s pitch when it has one, for its matches from then on. Not with API keys.
Body: WatchPitchMarking
| Answer | Body | When |
|---|---|---|
| 200 | { ok: true, pitch: boolean } | Kept. |
| 400 | { error: string, issues?: ValidationIssue[] } | The body doesn’t match, or it names neither matchId nor created. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No such match. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/watch/screen
Section titled “POST /v1/watch/screen”The match screen chosen on the watch. Settings > Match screen on the watch (UI-24), sent at its next sync. It becomes the account’s choice unless the website or a phone has changed it since (the latest change wins). An account with no settings yet keeps nothing. Not with API keys.
Body: WatchScreenChange
| Answer | Body | When |
|---|---|---|
| 200 | { applied: boolean } | Whether this change is now the account’s choice. |
| 400 | ValidationError | The body doesn’t match. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
POST /v1/watch/fee
Section titled “POST /v1/watch/fee”Mark a fee paid from the watch. The watch’s Paid toggle (SYNC-11), sent at its next sync. It changes only whether the fee is paid, and only if the match hasn’t been changed since (the latest change wins). Not with API keys.
Body: WatchFeeChange
| Answer | Body | When |
|---|---|---|
| 200 | { paid: boolean, applied: boolean } | Whether the fee is paid now, and whether this change made it so. |
| 400 | ValidationError | The body doesn’t match. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No such match, or it has no fee. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Places
Section titled “Places”Address search for placing venues on the map.
GET /v1/geocode
Section titled “GET /v1/geocode”Find an address. Through Nominatim (OpenStreetMap) by default, at most one lookup a second for the whole server, with answers cached. Only when GET /v1/meta lists geocode. API keys: need venues:write.
| Parameter | In | Type | Description |
|---|---|---|---|
q |
query | string | The address. |
| Answer | Body | When |
|---|---|---|
| 200 | { results: Place[] } | Up to five places. |
| 400 | ApiError | q is shorter than 3 or longer than 200 characters. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without venues:write; or the account is waiting for approval (USR-4). |
| 404 | ApiError | Address search is off on this server (an admin can switch it on, ADM-2). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
| 502 | ApiError | The address search service didn’t answer. |
The Laws of the Game quiz’s questions (QUIZ-1).
GET /v1/quiz
Section titled “GET /v1/quiz”The quiz’s questions. Every active question in a language, with the Laws and protocols they refer to, for the app to pick from. The phone apps keep the last copy, for no signal. Only when GET /v1/meta lists quiz. Not with API keys.
| Parameter | In | Type | Description |
|---|---|---|---|
language (optional) |
query | string | A two-letter language code; en by default. |
| Answer | Body | When |
|---|---|---|
| 200 | QuizBank | The questions. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key: this needs signing in; or the account is waiting for approval (USR-4). |
| 404 | ApiError | There are no questions in that language. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
Weather
Section titled “Weather”Forecasts for the user’s matches, and the weather finished matches were played in (WX-1 to WX-8). The server fetches and keeps forecasts; apps never call the weather service themselves.
GET /v1/weather
Section titled “GET /v1/weather”Weather for matches in a window. For the user’s matches kicking off between from and to that have a place on the map and a forecast (up to 16 days ahead) or kept weather (once played). Only when GET /v1/meta lists weather. API keys: need matches:read.
| Parameter | In | Type | Description |
|---|---|---|---|
from (optional) |
query | string | ISO 8601 time; 30 days ago by default. |
to (optional) |
query | string | ISO 8601 time; 17 days from now by default. At most 400 days after from. |
| Answer | Body | When |
|---|---|---|
| 200 | { items: MatchWeather[] } | The weather, one per match that has any. |
| 400 | ApiError | from or to isn’t a time, or they’re the wrong way round or too far apart. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without matches:read; or the account is waiting for approval (USR-4). |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
GET /v1/weather/{matchId}
Section titled “GET /v1/weather/{matchId}”One match’s weather. Only when GET /v1/meta lists weather. API keys: need matches:read.
| Parameter | In | Type | Description |
|---|---|---|---|
matchId |
path | Id | UUID made by the device that created the record. |
| Answer | Body | When |
|---|---|---|
| 200 | MatchWeather | The weather. |
| 401 | ApiError | Not signed in, the session has ended, or the API key is unknown or has expired. |
| 403 | ApiError | An API key without matches:read; or the account is waiting for approval (USR-4). |
| 404 | ApiError | No weather for this match: not found, no place on the map, or too far ahead. |
| 413 | ApiError | The request is too big. |
| 429 | ApiError | Too many tries from this address; the Retry-After header says how many seconds to wait. |
The API’s own types. Records and weather are in the data model: Match, MatchTemplate, MatchRecord, Venue, Competition, Referee, UserSettings and MatchWeather, each with a JSON Schema file in JSON Schema. The <Record>Input types are those records without updatedAt and deletedAt, and with id and version optional.
Account
Section titled “Account”The signed-in account, with what it may do.
| Field | Type | Description |
|---|---|---|
id |
string | |
name |
string | |
email |
string | |
status |
AccountStatus | |
admin |
boolean | Can invite and approve people (USR-1). |
AccountStatus
Section titled “AccountStatus”pending: made on a server that approves new accounts, and not approved yet (USR-4). It can sign in, but only GET /v1/account answers. suspended: an admin suspended it (USR-7); it can’t sign in, and its API keys and calendar feed stop working, but its data is kept.
“active” | “pending” | “suspended”
AccountStorage
Section titled “AccountStorage”| Field | Type | Description |
|---|---|---|
usedBytes |
integer | Photos and tracks on this account. |
limitBytes (optional) |
integer | How much it may keep (SEC-6); absent when this server sets no limit. |
ApiError
Section titled “ApiError”Every LawFive error (outside /v1/auth) has this shape; some add fields.
| Field | Type | Description |
|---|---|---|
error |
string | What went wrong, in words a person can read. |
ApiKey
Section titled “ApiKey”A key for scripts and integrations, with only the access it was given (KEY-1).
| Field | Type | Description |
|---|---|---|
id |
Id | |
name |
string | What it’s for, “RefSix import”. |
prefix |
string | The start of the key, to recognise it: lf_ and 8 characters. |
scopes |
ApiKeyScope[] | |
createdAt |
Timestamp | |
lastUsedAt (optional) |
Timestamp | To within five minutes; none until it’s used. |
expiresAt (optional) |
Timestamp | None for a key that doesn’t expire. |
ApiKeyScope
Section titled “ApiKeyScope”“matches:read” | “matches:write” | “match-templates:read” | “match-templates:write” | “match-records:read” | “match-records:write” | “venues:read” | “venues:write” | “competitions:read” | “competitions:write” | “referees:read” | “referees:write” | “settings:read” | “settings:write”
AppSignIn
Section titled “AppSignIn”A code that signs a phone in to this account. It works once, for two minutes.
| Field | Type | Description |
|---|---|---|
id |
string | For GET /v1/app-sign-in/{id}. |
code |
string | Eight letters and digits. |
link |
string | What the QR code holds: the web app’s Get the app page with the code in its fragment, which a phone’s camera can open. |
appLink |
string | The same as a lawfive:// link, which opens the app. |
server |
string | The address to sign in at, for someone typing the code. |
expiresAt |
Timestamp |
AppSignInCheck
Section titled “AppSignInCheck”Whose the code is, so the phone can ask before signing in.
| Field | Type | Description |
|---|---|---|
name |
string | |
email |
string | |
expiresAt |
Timestamp |
AppSignInCode
Section titled “AppSignInCode”| Field | Type | Description |
|---|---|---|
code |
string |
AppSignInResult
Section titled “AppSignInResult”| Field | Type | Description |
|---|---|---|
token |
string | The session token, sent from then on as Authorization: Bearer |
user |
{ id: string, name: string, email: string, status: AccountStatus } |
AppSignInStatus
Section titled “AppSignInStatus”| Field | Type | Description |
|---|---|---|
status |
“waiting” | “used” | “expired” | |
deviceName (optional) |
string | The phone that used it, when it said which. |
Attachment
Section titled “Attachment”A photo on a match, venue or referee (FILE-1). The files are private: fetching them needs the same sign-in as the rest of the API.
| Field | Type | Description |
|---|---|---|
id |
Id | |
recordType |
AttachmentRecordType | |
recordId |
Id | The match, venue or referee it’s on. |
contentType |
“image/jpeg” | “image/png” | “image/webp” | What the file is, worked out from its contents. |
size |
integer | The file’s size in bytes. |
width (optional) |
integer | Its width in pixels, when the upload gave it. |
height (optional) |
integer | |
caption (optional) |
string | |
position |
integer | Its place among the record’s attachments, from 0. A referee’s first photo is their picture. |
createdAt |
Timestamp | |
url |
string | Where to fetch the file: /v1/attachments/{id}/file, with the same sign-in. |
thumbUrl |
string | A small version for lists and galleries: /v1/attachments/{id}/thumb (the file itself when none was uploaded). |
AttachmentChange
Section titled “AttachmentChange”What to change about an attachment; leave out what stays the same.
| Field | Type | Description |
|---|---|---|
caption (optional) |
string | null | The new caption; null or an empty string removes it. |
position (optional) |
integer | Move it to this place among the record’s attachments (0 is first; a larger number moves it to the end). |
AttachmentRecordType
Section titled “AttachmentRecordType”The kinds of record that can have attachments.
“match” | “venue” | “referee”
AttachmentUpload
Section titled “AttachmentUpload”The form fields of an upload (multipart/form-data), beside the files: file (a JPEG, PNG or WebP of at most 10 MB) and, optionally, thumb (a small version, at most 1 MB).
| Field | Type | Description |
|---|---|---|
recordType |
AttachmentRecordType | |
recordId |
Id | |
caption (optional) |
string | |
width (optional) |
integer | The image’s width in pixels. |
height (optional) |
integer |
AuthError
Section titled “AuthError”An error from the account endpoints.
| Field | Type | Description |
|---|---|---|
message |
string | |
code |
string | For example INVALID_EMAIL_OR_PASSWORD or INVALID_ORIGIN. |
It may have more fields.
CalendarFeed
Section titled “CalendarFeed”The account’s private calendar address (CAL-1). Anyone with it can see the matches in it; make a new one to stop the old one working.
| Field | Type | Description |
|---|---|---|
url |
string | The feed (https), for calendar apps that ask for an address. |
webcalUrl |
string | The same feed as webcal://, which opens the calendar app to subscribe. |
createdAt |
Timestamp | |
lastFetchedAt (optional) |
Timestamp | When a calendar app last fetched it, to within an hour. |
Change
Section titled “Change”A record as it is now, after a change: created, updated or deleted (deletedAt set).
- { collection: “matches”, record: Match }
- { collection: “match-templates”, record: MatchTemplate }
- { collection: “match-records”, record: MatchRecord }
- { collection: “venues”, record: Venue }
- { collection: “competitions”, record: Competition }
- { collection: “referees”, record: Referee }
- { collection: “settings”, record: UserSettings }
ChangesPage
Section titled “ChangesPage”One page of the changes after a cursor, for sync.
| Field | Type | Description |
|---|---|---|
changes |
Change[] | Oldest first. A record changed several times appears once, as it is now. |
cursor |
integer | Send as since next time. Unchanged when there’s nothing new. |
more |
boolean | true when there are more changes after this page; ask again straight away. |
CreatedApiKey
Section titled “CreatedApiKey”A new key, with the key itself. The server keeps only a hash, so it can’t show it again.
| Field | Type | Description |
|---|---|---|
id |
Id | |
name |
string | What it’s for, “RefSix import”. |
prefix |
string | The start of the key, to recognise it: lf_ and 8 characters. |
scopes |
ApiKeyScope[] | |
createdAt |
Timestamp | |
lastUsedAt (optional) |
Timestamp | To within five minutes; none until it’s used. |
expiresAt (optional) |
Timestamp | None for a key that doesn’t expire. |
key |
string | The key itself, shown only this once: send it as Authorization: Bearer |
CreatedWebhook
Section titled “CreatedWebhook”A new webhook, with the secret its deliveries are signed with.
| Field | Type | Description |
|---|---|---|
id |
Id | |
url |
string | Where deliveries are POSTed. |
topics |
WebhookTopic[] | Which records’ changes it hears about. |
active |
boolean | Paused webhooks get no deliveries. |
createdAt |
Timestamp | |
lastDelivery (optional) |
{ at: Timestamp, status: “pending” | “delivered” | “failed”, responseCode?: integer } | The latest delivery and how it went. |
secret |
string | For checking a delivery’s X-LawFive-Signature, shown only this once. |
Device
Section titled “Device”Somewhere the account is signed in: a browser or a linked watch.
| Field | Type | Description |
|---|---|---|
id |
string | The session’s id, for DELETE /v1/devices/{id}. |
kind |
string | browser, or the app’s name from its X-LawFive-Device header (garmin). |
name |
string | For people: “Garmin Venu 2” (“Garmin watch” when it didn’t say which), “Chrome on macOS”. |
model (optional) |
string | A watch’s Garmin part number, “006-B3703-00”, when it gives one. |
firmware (optional) |
string | A watch’s firmware version, “12.30”, when it gives one. |
appVersion (optional) |
string | The version of LawFive on it (VER-3), “2026.10.30”, from the app= in its X-LawFive-Device header; missing for browsers and apps from before it. |
linkedAt |
Timestamp | When it signed in or was linked. |
lastActiveAt |
Timestamp | |
current |
boolean | true for the device making this request. |
DeviceCode
Section titled “DeviceCode”A code for linking a watch (RFC 8628).
| Field | Type | Description |
|---|---|---|
device_code |
string | Secret; the watch polls with it. |
user_code |
string | Eight letters and digits without look-alikes, shown on the watch. |
verification_uri |
string | The web app’s Link page. |
verification_uri_complete |
string | The Link page with the code filled in, for a QR code. |
expires_in |
integer | Seconds (600). |
interval |
integer | Seconds to wait between polls (5). |
DeviceToken
Section titled “DeviceToken”The watch’s token once someone has approved its code.
| Field | Type | Description |
|---|---|---|
access_token |
string | Send as Authorization: Bearer |
token_type |
“Bearer” | |
expires_in |
integer | |
scope |
string |
It may have more fields.
EmailChange
Section titled “EmailChange”| Field | Type | Description |
|---|---|---|
email |
string | |
password |
string | The account’s password, to confirm it’s them. |
ImportRow
Section titled “ImportRow”A row as a spreadsheet has it: column names (Home, home_colour and homeColour are the same column) and text, number or true/false values. Empty values change nothing.
{}
It may have more fields.
ImportedRow
Section titled “ImportedRow”| Field | Type | Description |
|---|---|---|
row |
integer | The row’s place in rows, from 1. |
action |
“add” | “update” | “skip” | skip: it has problems, so it wasn’t imported. |
matchId (optional) |
Id | The match it added or updated (or would). |
title (optional) |
string | The match in a few words, for showing the row: home v away. |
kickoff (optional) |
Timestamp | Its kickoff, once imported. |
problems |
string[] | Why it’s skipped, naming the column; empty when it isn’t. |
Invite
Section titled “Invite”An invitation to make an account, even when sign-up is closed (USR-3). It works once, for 14 days.
| Field | Type | Description |
|---|---|---|
id |
Id | |
email (optional) |
string | Who it’s for; only this address can use it. None for a link anyone can use once. |
name (optional) |
string | Their name, to fill in the sign-up form. |
link |
string | The link to send: the web app’s invitation page. |
createdBy |
string | The name of the admin who made it. |
createdAt |
Timestamp | |
expiresAt |
Timestamp | |
usedAt (optional) |
Timestamp | |
usedBy (optional) |
string | The name of the account made with it. |
emailedAt (optional) |
Timestamp | When it was emailed, if it was. |
InviteLookup
Section titled “InviteLookup”What the invitation page shows before someone signs up with it.
| Field | Type | Description |
|---|---|---|
email (optional) |
string | The address it’s for; the sign-up form uses it and doesn’t let it be changed. |
name (optional) |
string | |
invitedBy |
string | |
expiresAt |
Timestamp |
MatchImport
Section titled “MatchImport”Matches to add or update, from a spreadsheet, CSV or JSON file (IMP-1 to IMP-8).
| Field | Type | Description |
|---|---|---|
rows |
ImportRow[] | |
timeZone (optional) |
string | For kickoffs written without a UTC offset, when the row has no Time zone column; the web app sends the browser’s. UTC when not given. |
dateOrder (optional) |
“dmy” | “mdy” | How to read dates written with slashes or dots (7/11/2026); ISO dates (2026-11-07) need none. mdy when not given. |
dryRun (optional) |
boolean | Check every row and say what would happen, without saving anything (IMP-5). |
MatchImportFileFields
Section titled “MatchImportFileFields”The form fields of a file import (multipart/form-data), beside the file.
| Field | Type | Description |
|---|---|---|
timeZone (optional) |
string | For kickoffs written without a UTC offset, when the row has no Time zone column. |
dateOrder (optional) |
“dmy” | “mdy” | How to read dates written with slashes or dots (7/11/2026); ISO dates (2026-11-07) are always understood. |
dryRun (optional) |
“true” | “false” | true: check every row and say what would happen, without saving anything (IMP-5). |
MatchImportFileResult
Section titled “MatchImportFileResult”MatchImportResult, for a file (IMP-2).
| Field | Type | Description |
|---|---|---|
rows |
ImportedRow[] | Every row that had anything in it, in order. |
added |
integer | |
updated |
integer | |
skipped |
integer | |
created |
{ venues: string[], competitions: string[], referees: string[] } | Names the user had no record for, added (or, on a dry run, to be added) (IMP-4). |
ignoredColumns |
string[] | Columns the import doesn’t know, as written in the file. |
file |
{ kind: “table” | “json”, lines: integer[] } | How to point at a row in the file. |
MatchImportResult
Section titled “MatchImportResult”| Field | Type | Description |
|---|---|---|
rows |
ImportedRow[] | Every row that had anything in it, in order. |
added |
integer | |
updated |
integer | |
skipped |
integer | |
created |
{ venues: string[], competitions: string[], referees: string[] } | Names the user had no record for, added (or, on a dry run, to be added) (IMP-4). |
ignoredColumns |
string[] | Columns the import doesn’t know, as written in the file. |
MatchShare
Section titled “MatchShare”A match’s share link (DATA-7).
| Field | Type | Description |
|---|---|---|
url |
string | The public page: PUBLIC_URL/s/ |
createdAt |
Timestamp |
NewApiKey
Section titled “NewApiKey”| Field | Type | Description |
|---|---|---|
name |
string | |
scopes |
ApiKeyScope[] | At least one. |
expiresInDays (optional) |
integer | Leave out for a key that doesn’t expire. |
NewInvite
Section titled “NewInvite”| Field | Type | Description |
|---|---|---|
email (optional) |
string | Who it’s for; only this address can use it. |
name (optional) |
string | Their name, to fill in the sign-up form. |
send (optional) |
boolean | Email it to them (needs email set up on the server, and an email). |
OAuthError
Section titled “OAuthError”An error from the linking endpoints, as RFC 8628 has them.
| Field | Type | Description |
|---|---|---|
error |
string | authorization_pending, slow_down, expired_token, access_denied, invalid_request, invalid_client, invalid_grant or unauthorized. |
error_description (optional) |
string |
It may have more fields.
Person
Section titled “Person”An account on this server, as an admin sees it (USR-1).
| Field | Type | Description |
|---|---|---|
id |
string | |
name |
string | |
email |
string | |
status |
AccountStatus | |
admin |
boolean | |
adminFromSetting |
boolean | An admin because the server’s ADMIN_EMAILS lists them, which the web app can’t change. |
joinedAt |
Timestamp | |
lastActiveAt (optional) |
Timestamp | When one of their devices last used the account; none if never signed in since joining. |
invitedBy (optional) |
string | The name of the admin whose invitation they joined with. |
A place found by address search.
| Field | Type | Description |
|---|---|---|
name |
string | The full address. |
lat |
number | |
lon |
number |
PushHandle
Section titled “PushHandle”| Field | Type | Description |
|---|---|---|
handle |
string | null | What the push relay gave this phone (PUSH-2), which only it can read; null to stop notifications. |
RefereeMerge
Section titled “RefereeMerge”Referees to merge into one (DUP-3, DUP-4).
| Field | Type | Description |
|---|---|---|
from |
Id[] | The referees to merge into this one; they’re deleted. |
name |
string | The name to keep: one of the referees’ names or other names. The rest become other names. |
email (optional) |
string | When they have different emails, the one to keep; otherwise the one there is. |
phone (optional) |
string | When they have different phone numbers, the one to keep; otherwise the one there is. |
RefereeMergeResult
Section titled “RefereeMergeResult”| Field | Type | Description |
|---|---|---|
referee |
Referee | The referee they were merged into, as saved. |
merged |
integer | How many referees were merged into it (and deleted). |
matchesUpdated |
integer | Matches whose crew now names it, including crew typed as one of its names. |
ResetLink
Section titled “ResetLink”A link an admin sends someone who forgot their password, for servers without email (USR-7).
| Field | Type | Description |
|---|---|---|
link |
string | The web app’s page for choosing a new password. It works once. |
expiresAt |
Timestamp |
RestoreResult
Section titled “RestoreResult”What restoring an export did (DATA-4). Records the account already has a newer copy of are unchanged; running it again adds nothing.
| Field | Type | Description |
|---|---|---|
records |
{} | By collection: matches, venues, referees… |
tracks |
{ added: integer, updated: integer, unchanged: integer } | |
photos |
{ added: integer, updated: integer, unchanged: integer } | |
problems |
string[] | What couldn’t be restored and why, at most 50. |
ServerMeta
Section titled “ServerMeta”What this server is and offers, so apps can adapt to older or self-hosted servers.
| Field | Type | Description |
|---|---|---|
product |
“LawFive” | |
apiUrl (optional) |
string | The address apps should use for the API, which may differ from the one they came through (APP-13). |
apiVersion |
integer | Raised when the API changes in a way older apps can’t follow. |
version (optional) |
string | The server’s version (VER-1): the day of its last change, “2026.10.30” (“2026.10.30.2” for a second change that day), or “dev” when run from a checkout without git. Missing on servers from before it. |
commit (optional) |
string | The commit it was built from, “f5de635”. |
capabilities |
string[] | What this server offers: accounts, records, changes, tracks and stats (missing on servers from before them), devices, link, watch, people, invites, calendar and import, then email, geocode, push and weather when they’re switched on. Ignore names you don’t know. |
maps |
{ style?: { url: string, darkUrl?: string, attribution: string }, tileUrl: string, attribution: string, satellite?: { tileUrl: string, attribution: string } } | |
geocoder (optional) |
{ url: string } | The address search service behind GET /v1/geocode, to credit it. |
weather (optional) |
{ source: string } | Whose weather data GET /v1/weather uses, to credit it. |
firstAccount (optional) |
true | Nobody has an account on this server yet (USR-8): the first one can be made whatever signUp says, and unless ADMIN_EMAILS is set it’s the server’s admin. |
setupCodeNeeded (optional) |
true | The first account needs the code in the server’s logs (ADM-5). |
setupPending (optional) |
true | The first account hasn’t finished setting the server up (ADM-5). |
publicUrl (optional) |
string | The server’s own address (PUBLIC_URL), for the website to say if it isn’t HTTPS (ADM-5, ADM-6). |
signUp (optional) |
SignUpMode | Who can make an account (USR-2); missing on servers from before it, which were open or closed. |
ServerSettingView
Section titled “ServerSettingView”A server setting as the admin page shows it (ADM-1).
| Field | Type | Description |
|---|---|---|
key |
“signUp” | “smtpUrl” | “mailFrom” | “mapStyleUrl” | “mapStyleDarkUrl” | “mapStyleAttribution” | “mapTileUrl” | “mapAttribution” | “mapSatelliteUrl” | “mapSatelliteAttribution” | “geocoderUrl” | “weatherUrl” | “weatherHistoryUrl” | “weatherApiKey” | “weatherDailyLimit” | “webhookAllowPrivate” | “accountStorageLimitMb” | |
env |
string | Its environment variable. |
source |
“environment” | “admin” | “default” | environment: set there, so fixed; admin: set in the web app; default: neither. |
value (optional) |
string | The value in use, when it’s set and not a secret. |
secret (optional) |
true | Never sent back: set says whether it has a value. |
set (optional) |
boolean |
ServerSettings
Section titled “ServerSettings”Settings for the whole server that admins change in the web app (ADM-1, ADM-2), sending only those that change. One the environment sets is fixed there and can’t be changed.
| Field | Type | Description |
|---|---|---|
signUp (optional) |
SignUpMode | null | Who can make an account (USR-2, SIGN_UP). |
smtpUrl (optional) |
string | null | Email through an SMTP server: smtp://user:password@host:587 or smtps://…:465. A secret: never sent back (SMTP_URL). null or “” clears it, back to the default. |
mailFrom (optional) |
string | null | The sender, “LawFive [email protected]” (MAIL_FROM). null or “” clears it, back to the default. |
mapStyleUrl (optional) |
string | null | A MapLibre style for the map, or off for raster tiles (MAP_STYLE_URL). null or “” clears it, back to the default. |
mapStyleDarkUrl (optional) |
string | null | The style for dark mode (MAP_STYLE_DARK_URL). null or “” clears it, back to the default. |
mapStyleAttribution (optional) |
string | null | The style’s credit, as HTML (MAP_STYLE_ATTRIBUTION). null or “” clears it, back to the default. |
mapTileUrl (optional) |
string | null | Raster tiles, with {z}, {x} and {y} (MAP_TILE_URL). null or “” clears it, back to the default. |
mapAttribution (optional) |
string | null | The tiles’ credit, as HTML (MAP_ATTRIBUTION). null or “” clears it, back to the default. |
mapSatelliteUrl (optional) |
string | null | Satellite tiles, with the provider’s key (MAP_SATELLITE_URL). null or “” clears it, back to the default. |
mapSatelliteAttribution (optional) |
string | null | Their credit, as HTML (MAP_SATELLITE_ATTRIBUTION). null or “” clears it, back to the default. |
geocoderUrl (optional) |
string | null | Address search (Nominatim-compatible), or off (GEOCODER_URL). null or “” clears it, back to the default. |
weatherUrl (optional) |
string | null | Forecasts (Open-Meteo-compatible), or off (WEATHER_URL). null or “” clears it, back to the default. |
weatherHistoryUrl (optional) |
string | null | Past weather for played matches, or off (WEATHER_HISTORY_URL). null or “” clears it, back to the default. |
weatherApiKey (optional) |
string | null | For a paid plan. A secret: never sent back (WEATHER_API_KEY). null or “” clears it, back to the default. |
weatherDailyLimit (optional) |
string | null | Weather calls a day at most (WEATHER_DAILY_LIMIT). null or “” clears it, back to the default. |
webhookAllowPrivate (optional) |
string | null | true lets webhooks reach private and local addresses (WEBHOOK_ALLOW_PRIVATE). null or “” clears it, back to the default. |
accountStorageLimitMb (optional) |
string | null | How many megabytes of photos and tracks each account may keep; none means no limit (ACCOUNT_STORAGE_LIMIT_MB). null or “” clears it, back to the default. |
Session
Section titled “Session”A signed-in device.
| Field | Type | Description |
|---|---|---|
id |
string | |
userId |
string | |
token |
string | The bearer token. |
expiresAt |
string | 90 days after it was last renewed; a session is renewed once a day while it’s used. |
createdAt |
string | |
updatedAt |
string |
It may have more fields.
SharedMatch
Section titled “SharedMatch”What a match’s share link shows (DATA-7): never its notes, fee, crew, contacts, player names or photos.
| Field | Type | Description |
|---|---|---|
match |
Match | Without its notes, outlook, fee, crew, tags, match number or the ids of its venue and competition. |
record (optional) |
MatchRecord | Its match report, when it’s been played, without notes on events or team officials’ names. |
competition (optional) |
string | The competition’s name. |
venue (optional) |
string | The venue’s name. |
pitch (optional) |
string | The pitch’s name. |
distanceUnit |
DistanceUnit | The sharer’s, for the figures. |
track |
boolean | Whether GET /v1/shared/{token}/track has a track. |
SignOutOthers
Section titled “SignOutOthers”| Field | Type | Description |
|---|---|---|
scope |
“browsers” | “all” | browsers: other browsers only, so watches and phones stay linked. all: every other device too. |
SignUpMode
Section titled “SignUpMode”Who can make an account (USR-2): open, anyone; approval, anyone, but an admin approves the account before it can be used; closed, only people with an invitation (USR-3).
“open” | “approval” | “closed”
A LawFive account.
| Field | Type | Description |
|---|---|---|
id |
string | |
name |
string | |
email |
string | |
emailVerified |
boolean | |
image (optional) |
string | null | |
createdAt |
string | |
updatedAt |
string |
It may have more fields.
ValidationError
Section titled “ValidationError”The body doesn’t match the schema; issues lists each problem.
| Field | Type | Description |
|---|---|---|
error |
string | |
issues |
ValidationIssue[] |
ValidationIssue
Section titled “ValidationIssue”One problem with a request body, as Zod reports it.
| Field | Type | Description |
|---|---|---|
code |
string | |
path |
(string | number)[] | Where in the body, for example [“home”, “colour”]. |
message |
string |
It may have more fields.
WatchEvent
Section titled “WatchEvent”[code, team, period, seconds, player, extra]. code: G goal, GO own goal, GP penalty, GF direct free kick, Y caution, Y2 second caution, R send-off, S stoppage, E end of period, A abandoned, with the reason in extra (0 weather, 1 lightning, 2 crowd trouble, 3 not enough players, 4 other), PG and PM shoot-out kick scored and missed. team: 0 home, 1 away, -1 neither. period counts from 0. seconds: into the period, or -1 before kickoff, -2 at a break, -3 after full time, -4 in the shoot-out. player: shirt number or null. extra: a stoppage’s length in seconds.
[string, integer, integer, integer, integer | null, integer | null]
WatchFeeChange
Section titled “WatchFeeChange”A match’s fee marked paid or not paid on the watch (SYNC-11).
| Field | Type | Description |
|---|---|---|
matchId |
Id | |
paid |
boolean | |
at |
integer | When it was changed on the watch, in seconds since 1970. |
WatchMatch
Section titled “WatchMatch”A match in the form the Garmin watch starts one from (its setup keys), plus what its list shows. Watches have little memory, so the server does the converting.
| Field | Type | Description |
|---|---|---|
id |
Id | |
kickoff |
integer | null | Seconds since 1970 (UTC). |
title |
string | “STR v ROV”, for the watch’s list. |
name |
string | null | The match’s name, or its competition’s. |
where |
string | null | Venue and pitch, “Twin Creeks, Field 3”. |
role |
string | null | Referee, AR1, AR2 or 4th official. |
weather |
string | null | A short forecast in the user’s temperature unit, “54° rain” (WX-7). |
fee (optional) |
{ text: string, paid: boolean } | null | The fee, for the match’s screen and its Paid toggle (UI-19); absent from servers before it. |
crew (optional) |
([string, string])[] | The other officials, [role, name]: [“AR1”, “Sam Rivera”]. |
notes (optional) |
string | null | The match’s notes, shortened to 300 characters for the watch’s memory. |
home |
string | Short name shown on the watch. |
away |
string | |
homeName |
string | |
awayName |
string | |
homeColor |
integer | 0xRRGGBB, or -1 for none. |
awayColor |
integer | |
numPeriods |
integer | |
periodSec |
integer | |
halftimeSec |
integer | The break between periods. |
teamSize |
integer | |
midBreak |
boolean | A break halfway through each period (for example a water break). |
breakLenSec |
integer | |
extraTime |
boolean | |
etPeriods |
integer | |
etPeriodSec |
integer | |
penalties |
boolean | |
clockStyle |
0 | 1 | 2 | 0 counts up, 1 counts up from zero each period, 2 counts down. |
trackStoppage |
boolean | |
breakStoppage |
boolean | The halfway break’s time is added on as stoppage (TMR-12). |
goalScorers |
boolean | Ask the scorer’s number for each goal (EVT-10). |
goalTypes |
boolean |
WatchPitchMarking
Section titled “WatchPitchMarking”A pitch’s corners walked on the watch (TRK-13, SYNC-12).
| Field | Type | Description |
|---|---|---|
matchId (optional) |
Id | A planned match’s id. |
created (optional) |
integer | Or, for a quick match, when it was started on the watch (it has no id until it’s uploaded). |
corners |
([integer, integer])[] | [latitude, longitude] in millionths of a degree, as the watch’s tracks are: its JSON keeps only six significant figures of a decimal. |
at |
integer | When they were marked, in seconds since 1970. |
WatchScreenChange
Section titled “WatchScreenChange”The match screen as chosen on the watch, or as it’s sent to one (UI-24); the latest change wins.
| Field | Type | Description |
|---|---|---|
layout |
WatchLayout | |
secondClock |
ClockStyle | |
at |
integer | When it was changed on the watch, in seconds since 1970. |
WatchTrackChunk
Section titled “WatchTrackChunk”Part of a match’s track as the watch recorded it (TRK-7): GPS and heart rate about once a second during the periods.
| Field | Type | Description |
|---|---|---|
created |
integer | The match’s created time, as in its upload: which match the track is for. |
start |
integer | When the track starts (the first kickoff), in seconds since 1970. |
index |
integer | Which chunk this is, from 0. |
count |
integer | How many chunks the track has. |
device (optional) |
string | The watch model, “venu2”. |
data |
string | The chunk’s samples as the watch packs them, “dt,dlat,dlon,speed,hr;” each: tenths of a second since the sample before (empty for 10; the chunk’s first counts from start), millionths of a degree moved since the chunk’s position before (the first is whole; both empty without a new fix), the speed the watch measured in cm/s, and beats a minute. Empty fields mean unknown; trailing ones are left off. |
WatchTrackResult
Section titled “WatchTrackResult”| Field | Type | Description |
|---|---|---|
received |
integer | How many of the track’s chunks the server has. |
count |
integer | |
track |
“waiting” | “saved” | “kept” | “dropped” | waiting: for more chunks or the match; saved: the track is on the match report; kept: the match report already had a track (from a file, say), which stays; dropped: nothing in it could be used (no positions or heart rate). |
WatchUpload
Section titled “WatchUpload”A finished match as the watch saved it. The server stores it as a match record.
| Field | Type | Description |
|---|---|---|
created |
integer | When the match was started on the watch, in seconds since 1970. |
matchId (optional) |
Id | null | The planned match it was started from; none for a quick match. |
device (optional) |
string | The watch model, “venu2”. |
home |
string | |
away |
string | |
homeName (optional) |
string | |
awayName (optional) |
string | |
homeColor (optional) |
integer | |
awayColor (optional) |
integer | |
numPeriods |
integer | |
periodSec |
integer | |
halftimeSec |
integer | |
teamSize (optional) |
integer | |
breakAtSec (optional) |
integer | When the halfway break comes, or -1 for none. |
breakLenSec (optional) |
integer | |
extraTime (optional) |
boolean | |
etPeriods (optional) |
integer | |
etPeriodSec (optional) |
integer | |
penalties (optional) |
boolean | |
clockStyle (optional) |
integer | |
trackStoppage (optional) |
boolean | |
breakStoppage (optional) |
boolean | |
goalScorers (optional) |
boolean | |
goalTypes (optional) |
boolean | |
score |
[integer, integer] | [home, away]. |
pens (optional) |
[integer, integer] | null | Shoot-out score, [home, away]. |
distanceM (optional) |
number | null | How far the referee ran, in metres. |
startedAt (optional) |
integer[] | When each period kicked off, in seconds since 1970, for the track’s figures (TRK-3). |
kickoff (optional) |
integer | The team that kicked off the first period: 0 home, 1 away, -1 not chosen. |
homeHeading (optional) |
number | null | The compass heading of the goal the home team attacked in the first period, in radians from true north, when the referee recorded it (PRE-8). |
events |
WatchEvent[] | |
diagnostics (optional) |
{ battery?: ([integer, integer, number])[], app?: string, firmware?: string, gpsFirstFixSec?: integer, gpsSamples?: integer, gpsMissed?: integer, restarts?: integer } | How the recording went, for tracing problems (DIAG-1). |
WatchUploadResult
Section titled “WatchUploadResult”| Field | Type | Description |
|---|---|---|
recordId |
Id | The match record. The same every time this match is sent, so sending it again updates it. |
matchId |
Id | The planned match, or the one made for a quick match. |
Webhook
Section titled “Webhook”A URL that hears about changes to the account’s records (HOOK-1).
| Field | Type | Description |
|---|---|---|
id |
Id | |
url |
string | Where deliveries are POSTed. |
topics |
WebhookTopic[] | Which records’ changes it hears about. |
active |
boolean | Paused webhooks get no deliveries. |
createdAt |
Timestamp | |
lastDelivery (optional) |
{ at: Timestamp, status: “pending” | “delivered” | “failed”, responseCode?: integer } | The latest delivery and how it went. |
WebhookDelivery
Section titled “WebhookDelivery”One change sent to a webhook, with how it went.
| Field | Type | Description |
|---|---|---|
id |
Id | Also sent as X-LawFive-Delivery. |
event |
string | match.created, matchRecord.updated, venue.deleted… |
status |
“pending” | “delivered” | “failed” | pending: waiting for its next try; failed: gave up after five tries. |
attempts |
integer | |
responseCode (optional) |
integer | |
error (optional) |
string | Why the last try failed, when it didn’t get an answer. |
test |
boolean | Sent with Send test. |
createdAt |
Timestamp | |
deliveredAt (optional) |
Timestamp |
WebhookPayload
Section titled “WebhookPayload”What each delivery POSTs, as JSON. It’s signed: X-LawFive-Signature is t=
| Field | Type | Description |
|---|---|---|
id |
Id | The delivery’s id; the same on every retry, so a receiver can ignore one it has had. |
event |
string | |
createdAt |
Timestamp | When the change was made. |
test (optional) |
boolean | true for Send test; data is then an example. |
data |
{} | The record as stored (as GET returns it); for deleted, its id, version and deletedAt. |
WebhookTopic
Section titled “WebhookTopic”The kinds of record a webhook can hear about: matches, match reports (results), venues, referees, competitions and match templates.
“match” | “matchRecord” | “venue” | “referee” | “competition” | “matchTemplate”