TrafficGrid — API Design Specification¶
Version 1.0 | 2026
This document defines the complete REST API for the TrafficGrid platform. It serves as the contract between the backend and all consumers — mobile apps, web frontend, and third-party integrations.
Table of Contents¶
1. Conventions¶
Base URL¶
https://api.trafficgrid.co.zw/api/v1
URL Structure¶
- All resource URLs use plural nouns —
/fines,/vehicles,/users - HTTP verbs express the action — never put verbs in URLs
- Nested URLs express relationships —
/vehicles/{id}/fines - Nesting is limited to two levels — deeper relationships are accessed directly
- Query parameters are used for filtering, sorting, and pagination — not for identifying resources
HTTP Verbs¶
| Verb | Usage |
|---|---|
GET |
Fetch a resource or collection. Never modifies state. |
POST |
Create a new resource. |
PUT |
Replace a resource entirely. |
PATCH |
Partially update a resource. |
DELETE |
Remove or deactivate a resource. |
Date & Time Format¶
All timestamps use ISO 8601 in UTC: 2026-03-06T10:30:00Z All dates use: 2026-03-06
Naming Convention¶
All request and response fields use camelCase.
Money¶
Every amount — on fine categories, fines, payments and payment items — is an integer number of cents (minor units), sent and returned as a JSON number. 20000 means 200.00. Decimals are never sent or returned; a client formats the value for display.
Currency Codes¶
A record that carries an amount also carries a currency: the code of a currency in system-parameters-service ("USD", "ZWG"; see Other reference data). A fine category's currency must be an active currency; a fine takes its category's currency when issued; a payment takes the currency of its items, which must all be the same. Each currency's rate is its units per US dollar (USD 1), which clients use to convert amounts for display.
People on Records¶
Wherever a record names a person it returns { "id": uuid | null, "fullName": string | null }. The name is a snapshot taken when the record was written (from the gateway's X-User-Name header — see §2), never looked up on read, so a later rename does not change history. id is null for a person with no account, such as the walk-in payer of a cash payment.
Role Abbreviations Used in This Document¶
| Abbreviation | Role |
|---|---|
PUBLIC |
No authentication required |
CITIZEN |
Authenticated citizen |
ZRP |
ZRP officer |
COUNCIL |
Council officer |
PARKING |
Parking officer |
ANY_OFFICER |
Any of ZRP, COUNCIL, or PARKING |
AUTH_ADMIN |
Authority admin |
SUPER |
Super admin |
AUTHENTICATED |
Any authenticated user regardless of role |
2. Authentication¶
All protected endpoints require a Bearer token in the Authorization header:
Authorization: Bearer <access_token>
Access tokens expire after 15 minutes. Use the refresh endpoint to obtain a new one.
Refresh tokens are delivered as an httpOnly cookie for web clients and in the response body for mobile clients. They expire after 7 days.
Officers and admins sign in through POST /auth/login (the admin console). Citizens sign in through POST /auth/citizen-login (the TrafficGrid mobile app). Each endpoint refuses the other kind of account with 403.
Access token claims¶
| Claim | Type | Description |
|---|---|---|
sub |
UUID | The user's id |
role |
string | The role name, always bare — ZRP_OFFICER, never ROLE_ZRP_OFFICER |
permissions |
string[] | The permission codes granted to that role (see Roles and Permissions.md) |
organizationId |
UUID | The organisation the user belongs to. Absent for users who belong to none (super admins, citizens). |
name |
string | The user's full name. Access tokens only. |
email |
string | The user's email address. Access tokens only. |
type |
ACCESS | REFRESH |
Refresh tokens carry no permissions, name or email |
Identity headers¶
The API gateway validates the token with GET /auth/validate, removes the Authorization header, and forwards the caller's identity to downstream services as headers:
| Header | Value |
|---|---|
X-User-Id |
The user's id |
X-User-Role |
The bare role name |
X-User-Permissions |
The permission codes |
X-User-Name |
The user's full name, URL-encoded UTF-8 (spaces as %20). Sent only when the user has a name. |
X-Organisation-Id |
The user's organisation id. Sent only when the user has one. |
The gateway first strips any copy of these headers the client sent (including X-Organization-Id), so a caller cannot claim another identity. Services snapshot the actor's name from X-User-Name; on a direct call that bypasses the gateway they fall back to the Bearer token's name claim.
3. Response Envelope¶
Every response from the API — success or error — is wrapped in the same envelope. Clients must always check success before reading data.
Success Envelope¶
{
"success": true,
"data": { },
"message": null,
"errors": null
}
Error Envelope¶
{
"success": false,
"data": null,
"message": "Human-readable summary of what went wrong",
"errors": [
"Specific error detail 1",
"Specific error detail 2"
]
}
All response examples in this document show the complete envelope including data. This is exactly what the client receives.
4. Pagination¶
Collection endpoints are paged with Spring's standard query parameters. Paginated responses nest metadata inside data: content (array), page, size, totalElements, totalPages, first, last.
Query Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer | 0 |
Zero-based page number |
size |
integer | 20 |
Number of items per page |
sort |
string | varies | field or field,asc / field,desc; may be repeated. Each endpoint lists its default and, where it restricts them, the fields it accepts (any other field is a 400). |
Paginated Response Structure¶
Paginated responses nest the page metadata inside data. The response's page is one-based (the request's page=0 returns "page": 1). auth-service's GET /users is the exception: it returns Spring's own page shape (see §6.10).
{
"success": true,
"data": {
"content": [ ],
"page": 1,
"size": 20,
"totalElements": 143,
"totalPages": 8,
"first": true,
"last": false
},
"message": null
}
5. Error Handling¶
HTTP Status Codes¶
| Code | Meaning | When Used |
|---|---|---|
200 OK |
Success | Successful GET, PATCH, PUT |
201 Created |
Resource created | Successful POST that creates a resource |
204 No Content |
Success, no body | Successful DELETE or logout |
400 Bad Request |
Invalid input | Missing fields, wrong types, constraint violations |
401 Unauthorized |
Not authenticated | Missing, expired, or invalid token |
403 Forbidden |
Not authorised | Authenticated but wrong role or accessing another user's resource |
404 Not Found |
Resource missing | ID that doesn't exist |
409 Conflict |
State conflict | Paying an already-paid fine, duplicate email on register |
422 Unprocessable Entity |
Business rule violated | Semantically valid request that violates domain logic |
500 Internal Server Error |
Server fault | Unhandled exception — never returned intentionally |
Validation Error Example¶
{
"success": false,
"data": null,
"message": "Validation failed",
"errors": [
"fullName must not be blank",
"email must be a valid email address",
"phoneNumber must not be blank"
]
}
6. Modules¶
6.1 Auth¶
POST /auth/register¶
Registers a new citizen account. Officers are created by admins — see 6.10 Admin — Users.
Auth: PUBLIC
Service: auth-service
Request Body:
{
"fullName": "Tendai Moyo",
"email": "tendai.moyo@gmail.com",
"phoneNumber": "+263771234567",
"nationalId": "63-123456-A-21",
"password": "SecurePass123!"
}
| Field | Type | Required | Rules |
|---|---|---|---|
fullName |
string | ✅ | Min 2 characters |
email |
string | ✅ | Valid email format, must be unique |
phoneNumber |
string | ✅ | Must be unique |
nationalId |
string | ✅ | Must be unique |
password |
string | ✅ | Min 8 characters |
Response: 201 Created
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"fullName": "Tendai Moyo",
"email": "tendai.moyo@gmail.com",
"phoneNumber": "+263771234567",
"role": "CITIZEN"
}
},
"message": null
}
Refresh token is set as an
httpOnlycookie and also included in the response body for mobile clients.
Error Cases:
| Status | Condition |
|---|---|
400 |
Any required field missing or invalid |
409 |
Email, phone number, or national ID already registered |
POST /auth/login¶
Authenticates an officer or admin and issues tokens. Used by the admin console. Citizen accounts are refused — they sign in through /auth/citizen-login.
Auth: PUBLIC
Service: auth-service
Request Body:
{
"email": "tendai.moyo@gmail.com",
"password": "SecurePass123!"
}
Response: 200 OK
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
},
"message": null
}
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing email or password |
401 |
Invalid credentials, or the account is deactivated |
403 |
The account is a citizen account (errors key CITIZEN_LOGIN_REQUIRED) |
The password is checked before the role, so a 403 is only ever returned to someone who knows the password.
POST /auth/citizen-login¶
Authenticates a citizen and issues tokens. Used by the TrafficGrid mobile app. Officer and admin accounts are refused — they sign in through /auth/login.
Auth: PUBLIC
Service: auth-service
Request Body: the same as /auth/login.
{
"email": "tendai.moyo@gmail.com",
"password": "SecurePass123!"
}
Response: 200 OK — the same as /auth/login.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing email or password |
401 |
Invalid credentials, or the account is deactivated |
403 |
The account is not a citizen account (errors key CONSOLE_LOGIN_REQUIRED) |
POST /auth/refresh¶
Issues a new access token using a valid refresh token. Rotates the refresh token on every call.
Auth: PUBLIC
Service: auth-service
Request: Refresh token is read from the refreshToken httpOnly cookie automatically (web). Mobile clients send it in the request body.
{
"refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
}
Response: 200 OK
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9..."
},
"message": null
}
Error Cases:
| Status | Condition |
|---|---|
401 |
Refresh token missing, malformed, expired, or already revoked (errors key REFRESH_TOKEN_MISSING or REFRESH_TOKEN_INVALID) |
401 |
The account has been deactivated (errors key ACCOUNT_DEACTIVATED) — a deactivated user's access ends at their next refresh |
A failed refresh never returns 500. Clients should treat any 401 from this endpoint as the end of the session and not retry.
POST /auth/logout¶
Revokes the current access token and refresh token. Clears the refresh token cookie.
Auth: AUTHENTICATED
Service: auth-service
Request Body: None
Response: 204 No Content
POST /auth/forgot-password¶
Sends a one-time password reset link to the account with this email, if an active one exists. The response is the same whether or not it does, so this endpoint cannot be used to find out which emails have accounts.
Auth: PUBLIC
Service: auth-service
Request Body:
{
"email": "j.dube@zrp.gov.zw"
}
Response: 202 Accepted
{
"success": true,
"data": null,
"message": "If the account exists, a reset link has been sent."
}
The link is {password-reset.link-base-url}?token=<token> (default http://localhost:4200/reset-password, the admin portal's reset page) and works once, for one hour.
Email is not delivered yet. Nothing on the platform sends email, so auth-service logs the link instead (
LoggingPasswordResetLinkSender). Replace that bean once notification-service can send email.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or malformed email |
POST /auth/reset-password¶
Sets a new password with the token from a reset link. A token works once; using it also spends every other outstanding link for the account.
A reset ends every existing session: refresh tokens issued at or before the reset (to the second) are refused with 401, so anyone holding an old session must sign in again with the new password. A deactivated account's links stop working.
Auth: PUBLIC
Service: auth-service
Request Body:
{
"token": "q3Zl0x...",
"password": "NewSecurePass123!"
}
| Field | Type | Required | Rules |
|---|---|---|---|
token |
string | ✅ | From the reset link |
password |
string | ✅ | Min 8 characters |
Response: 204 No Content
Error Cases:
| Status | Condition |
|---|---|
400 |
The token is unknown, already used or expired, the account is deactivated, or the password is too short |
GET /auth/validate¶
Checks an access token and returns who it belongs to. The API gateway calls this on every protected request and turns the result into the identity headers; clients do not need it.
Auth: the Bearer token being checked
Service: auth-service
Response: 200 OK
{
"success": true,
"data": {
"userId": "user-uuid-...",
"role": "ZRP_OFFICER",
"permissions": ["FINE_CREATE", "FINE_READ", "VEHICLE_READ"],
"fullName": "Const. John Dube",
"email": "j.dube@zrp.gov.zw",
"organizationId": "org-uuid-..."
},
"message": null
}
organizationId is null for users who belong to no organisation (citizens, the super admin).
Error Cases:
| Status | Condition |
|---|---|
401 |
Token missing, malformed, expired or revoked |
6.2 Vehicles¶
POST /vehicles¶
Creates a new vehicle record in the system. Used by officers when logging a fine against a vehicle that doesn't yet exist in the database.
Auth: ANY_OFFICER
Service: vehicle-service
Request Body:
{
"numberPlate": "ABC 1234",
"vin": "1HGBH41JXMN109186",
"engineNumber": "ENG123456",
"make": "Toyota",
"model": "Hilux",
"year": 2019,
"color": "White",
"vehicleType": "TRUCK"
}
| Field | Type | Required | Rules |
|---|---|---|---|
numberPlate |
string | ✅ | Must be unique |
vin |
string | ❌ | Must be unique if provided |
engineNumber |
string | ❌ | |
make |
string | ❌ | |
model |
string | ❌ | |
year |
integer | ❌ | 1900 – current year |
color |
string | ❌ | |
vehicleType |
VehicleType |
✅ | See enum reference in data model |
Response: 201 Created
{
"success": true,
"data": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"numberPlate": "ABC 1234",
"vin": "1HGBH41JXMN109186",
"engineNumber": "ENG123456",
"make": "Toyota",
"model": "Hilux",
"year": 2019,
"color": "White",
"vehicleType": "TRUCK",
"isVerified": false,
"createdAt": "2026-03-06T10:30:00Z"
},
"message": null
}
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing required fields or invalid field values |
409 |
Number plate or VIN already exists |
GET /vehicles/{id}¶
Fetches full details for a vehicle by ID.
Auth: ANY_OFFICER, SUPER, AUTH_ADMIN
Citizens access their vehicles via
/citizens/me/vehicles— they cannot look up arbitrary vehicles by ID.
Response: 200 OK
{
"success": true,
"data": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"numberPlate": "ABC 1234",
"vin": "1HGBH41JXMN109186",
"engineNumber": "ENG123456",
"make": "Toyota",
"model": "Hilux",
"year": 2019,
"color": "White",
"vehicleType": "TRUCK",
"isVerified": false,
"createdAt": "2026-03-06T10:30:00Z",
"updatedAt": "2026-03-06T10:30:00Z"
},
"message": null
}
Error Cases:
| Status | Condition |
|---|---|
404 |
Vehicle not found |
GET /vehicles/{id}/owner¶
Returns the vehicle's owner: the citizen whose claim to it CVR has verified (see 6.4 Citizen Vehicles). A vehicle has at most one owner. Meant for service-to-service calls, such as deciding who to tell about a fine.
Auth: permission VEHICLE_READ
Service: vehicle-service
Response: 200 OK
{
"success": true,
"data": {
"citizenId": "3f1c2a9e-...",
"linkId": "link-uuid-..."
},
"message": "Vehicle Owner Retrieval Successful"
}
| Field | Meaning |
|---|---|
citizenId |
The owning citizen's user id |
linkId |
The id of their VERIFIED link to the vehicle |
Error Cases:
| Status | Condition |
|---|---|
404 |
The vehicle has no verified owner: nobody has claimed it, the claim is still PENDING_VERIFICATION or was REJECTED, or the vehicle doesn't exist |
GET /internal/v1/vehicles/{id}/owner¶
The same lookup for services that act with no user behind them, such as notification-service reacting to a Kafka event. It returns the same body and the same 404 as GET /vehicles/{id}/owner.
Not routed by the gateway. Callers reach vehicle-service directly (notification-service uses http://vehicle-service:8084 by default, overridable with VEHICLE_SERVICE_INTERNAL_URL).
Auth: the shared service key, sent as the X-Service-Key header. vehicle-service compares it in constant time with its trafficgrid.internal.service-key (the SERVICE_API_KEY environment variable). No user token is read, and the gateway's identity headers are ignored on /internal/**. With no key configured, every call is refused, so the endpoint stays closed until SERVICE_API_KEY is set to the same value for vehicle-service and its callers.
Error Cases:
| Status | Condition |
|---|---|
401 |
X-Service-Key is missing or wrong, or no key is configured (INVALID_SERVICE_KEY) |
404 |
The vehicle has no verified owner |
GET /vehicles¶
Searches vehicles. Primary use case is officers searching by number plate in the field.
Auth: ANY_OFFICER, SUPER, AUTH_ADMIN
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
numberPlate |
string | Part of the plate, ignoring case and spaces |
make |
string | Part of the make, ignoring case |
color |
string | Part of the colour, ignoring case |
q |
string | Free-text search: part of the plate (spaces ignored), make, model or VIN, ignoring case |
vehicleType |
VehicleType |
Filter by type |
isVerified |
boolean | Filter by verification status |
page |
integer | See pagination |
size |
integer | See pagination |
Response: 200 OK
{
"success": true,
"data": {
"content": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"numberPlate": "ABC 1234",
"make": "Toyota",
"model": "Hilux",
"year": 2019,
"color": "White",
"vehicleType": "TRUCK",
"isVerified": false
}
],
"page": 0,
"size": 20,
"totalElements": 1,
"totalPages": 1,
"first": true,
"last": true
},
"message": null
}
GET /vehicles/{id}/fines¶
Returns all fines issued against a specific vehicle.
Auth: ANY_OFFICER, SUPER, AUTH_ADMIN
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
status |
FineStatus |
Filter by fine status |
from |
date | Filter by issue date range start |
to |
date | Filter by issue date range end |
page |
integer | See pagination |
size |
integer | See pagination |
sort |
string | See pagination |
Response: 200 OK — paginated, same envelope shape as GET /vehicles. See 6.6 Fines for the fine object shape inside content.
6.3 Vehicle Documents¶
Every document returns two server-set fields:
| Field | Values | Meaning |
|---|---|---|
source |
SELF_DECLARED | ZINARA |
Where the details came from. A document a citizen adds is SELF_DECLARED; one an officer adds is ZINARA (recorded from ZINARA's records). |
expiryStatus |
VALID | EXPIRING | EXPIRED |
Computed on every read: EXPIRED once expiryDate has passed, EXPIRING when it falls within the next 30 days (inclusive), otherwise VALID. |
isSelfDeclared is kept for older clients and is true exactly when source is SELF_DECLARED.
GET /vehicles/{vehicleId}/documents¶
Returns all documents registered for a vehicle.
Auth: ANY_OFFICER, SUPER, AUTH_ADMIN, CITIZEN (own linked vehicles only)
Response: 200 OK
{
"success": true,
"data": [
{
"id": "a1b2c3d4-...",
"vehicleId": "7c9e6679-...",
"documentType": "VEHICLE_LICENSE",
"expiryDate": "2026-12-31",
"isSelfDeclared": true,
"source": "SELF_DECLARED",
"expiryStatus": "VALID",
"createdAt": "2026-03-06T10:30:00Z",
"updatedAt": "2026-03-06T10:30:00Z"
}
],
"message": null
}
POST /vehicles/{vehicleId}/documents¶
Adds a document record for a vehicle. Citizens can self-declare documents for their own linked vehicles.
Auth: ANY_OFFICER, SUPER, CITIZEN (own linked vehicles only)
Request Body:
{
"documentType": "VEHICLE_LICENSE",
"expiryDate": "2026-12-31"
}
| Field | Type | Required | Rules |
|---|---|---|---|
documentType |
DocumentType |
✅ | See enum reference in data model |
expiryDate |
date | ✅ | Must be a future date |
Response: 201 Created
{
"success": true,
"data": {
"id": "a1b2c3d4-...",
"vehicleId": "7c9e6679-...",
"documentType": "VEHICLE_LICENSE",
"expiryDate": "2026-12-31",
"isSelfDeclared": true,
"source": "SELF_DECLARED",
"expiryStatus": "VALID",
"createdAt": "2026-03-06T10:30:00Z"
},
"message": null
}
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing fields or invalid date |
403 |
Citizen trying to add a document to a vehicle they haven't linked |
404 |
Vehicle not found |
PATCH /vehicles/{vehicleId}/documents/{documentId}¶
Updates the expiry date of an existing document.
Auth: ANY_OFFICER, SUPER, CITIZEN (own linked vehicles only)
Request Body:
{
"expiryDate": "2027-12-31"
}
Response: 200 OK
{
"success": true,
"data": {
"id": "a1b2c3d4-...",
"vehicleId": "7c9e6679-...",
"documentType": "VEHICLE_LICENSE",
"expiryDate": "2027-12-31",
"isSelfDeclared": true,
"source": "SELF_DECLARED",
"expiryStatus": "VALID",
"createdAt": "2026-03-06T10:30:00Z",
"updatedAt": "2026-03-06T11:00:00Z"
},
"message": null
}
6.4 Citizen Vehicles¶
These endpoints allow citizens to link and manage vehicles on their own account.
Linking a vehicle is a claim of ownership that the Central Vehicle Registry (CVR) verifies. Each link carries a verificationStatus:
| Value | Meaning |
|---|---|
PENDING_VERIFICATION |
Claimed and waiting for CVR. The vehicle shows in the citizen's list, but nothing else treats it as theirs yet. |
VERIFIED |
CVR confirmed the citizen owns the vehicle. The citizen is its owner. |
REJECTED |
CVR refused the claim. The link is kept for audit but never returned, and the vehicle can be claimed again. |
The lifecycle:
POST /citizens/me/vehiclescreates the link asPENDING_VERIFICATIONand submits it to CVR (today the mock CVR). If CVR can't be reached, the link is still created and the submission is retried later.- vehicle-service polls CVR for every pending link (every 30 seconds by default,
CVR_POLL_INTERVAL). When CVR decides, the link becomesVERIFIED(with its verification time) orREJECTED, and vehicle-service publishesVERIFY_CITIZEN_VEHICLEorREJECT_CITIZEN_VEHICLEontrafficgrid.vehicles(seeKafka Design.md). A CVR error leaves the link pending for the next poll. - Unlinking deletes a pending or verified link and frees the vehicle.
One owner per vehicle. While a vehicle has a PENDING_VERIFICATION or VERIFIED link, nobody else can claim it.
Only verified links count. Everywhere a citizen's vehicles grant access, only VERIFIED links do:
- a vehicle's documents (
/vehicles/{vehicleId}/documents, "own linked vehicles only") - the owner lookup (
GET /vehicles/{id}/owner) - the citizen's fines and disputes in payments-service (
GET /citizens/me/fines,GET /fines,GET /fines/{id},POST /fines/{id}/dispute)
Fines on a vehicle whose claim is pending are hidden from the citizen, exactly as if the vehicle were someone else's. Links made before verification existed were migrated as VERIFIED.
GET /citizens/me/vehicles¶
Returns the authenticated citizen's pending and verified vehicles, primary first, then most recently linked. Rejected links are never returned. A client shows a pending link as "Verifying…" with no actions.
Auth: CITIZEN
Response: 200 OK
{
"success": true,
"data": [
{
"id": "link-uuid-...",
"vehicle": {
"id": "7c9e6679-...",
"numberPlate": "ABC 1234",
"make": "Toyota",
"model": "Hilux",
"year": 2019,
"color": "White",
"vehicleType": "TRUCK",
"isVerified": false
},
"isPrimary": true,
"linkedAt": "2026-03-06T10:30:00Z",
"verificationStatus": "VERIFIED",
"pendingFines": 2,
"documents": [
{
"documentType": "VEHICLE_LICENSE",
"expiryDate": "2026-12-31",
"isSelfDeclared": true,
"source": "SELF_DECLARED",
"expiryStatus": "VALID"
}
]
}
],
"message": null
}
POST /citizens/me/vehicles¶
Claims a vehicle for the authenticated citizen's account by number plate. The link starts PENDING_VERIFICATION and is submitted to CVR; see the lifecycle above.
Auth: CITIZEN
Request Body:
{
"numberPlate": "ABC 1234",
"isPrimary": false
}
| Field | Type | Required | Rules |
|---|---|---|---|
numberPlate |
string | ✅ | Vehicle must exist in the system |
isPrimary |
boolean | ❌ | Default false. Setting true unsets primary on all other linked vehicles. |
Response: 201 Created
{
"success": true,
"data": {
"id": "link-uuid-...",
"vehicle": {
"id": "7c9e6679-...",
"numberPlate": "ABC 1234",
"make": "Toyota",
"model": "Hilux",
"year": 2019,
"color": "White",
"vehicleType": "TRUCK"
},
"isPrimary": false,
"linkedAt": "2026-03-06T10:30:00Z",
"verificationStatus": "PENDING_VERIFICATION"
},
"message": null
}
Error Cases:
| Status | Condition |
|---|---|
404 |
No vehicle found with that number plate |
409 |
The caller already has a pending or verified link to this vehicle: message Citizen has already linked this vehicle |
409 |
Another citizen has a pending or verified link to this vehicle: message This vehicle is already registered to another account. Nothing about the other citizen is revealed. The app adds "If you believe you own it, contact support." |
A vehicle whose only claims were rejected can be claimed again, including by a citizen whose earlier claim was rejected.
DELETE /citizens/me/vehicles/{linkId}¶
Removes a pending or verified vehicle link from the citizen's account and frees the vehicle for another claim. Does not delete the vehicle itself. A rejected link is kept for audit and answers 404.
Auth: CITIZEN
Response: 204 No Content
Error Cases:
| Status | Condition |
|---|---|
403 |
Link does not belong to the authenticated citizen |
404 |
Link not found, or it is REJECTED |
GET /citizens/me/fines¶
Returns all fines across the authenticated citizen's verified vehicles. Served by payments-service, which asks vehicle-service for the caller's links and keeps only VERIFIED ones; fines on a vehicle still being verified are not listed. If vehicle-service can't answer, the request fails with 503 rather than answering empty or unscoped.
Auth: role CITIZEN and permission FINE_READ
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
status |
FineStatus |
Filter by status |
vehicleId |
UUID | Filter to a specific linked vehicle |
page |
integer | See pagination |
size |
integer | See pagination |
Response: 200 OK — paginated, same envelope shape as other paginated endpoints. See 6.6 Fines for the fine object shape inside content.
6.4.1 Mock CVR¶
Temporary: stands in for the Central Vehicle Registry until the real integration exists. It behaves like an asynchronous registry: a request is accepted now and decided later. vehicle-service calls it through its CvrVerificationClient at trafficgrid.cvr.base-url (CVR_BASE_URL, by default vehicle-service itself), so swapping in the real CVR changes only that client.
Served by vehicle-service under /api/v1/mock/cvr/verifications. Not routed by the gateway: only vehicle-service calls it. Auth: none, as an external registry would be open to this service.
The decision is made when a request is read, once trafficgrid.cvr.verification-delay (CVR_VERIFICATION_DELAY, default PT2M) has passed since it was submitted:
- a plate whose last character is
0isREJECTED - every other plate is
VERIFIED
Requests are stored in mock_cvr_verifications (see Data Model.md §2.4), so a pending request survives a restart.
POST /mock/cvr/verifications¶
Submits an ownership claim.
Request Body:
{
"numberPlate": "ABC 1234",
"citizenId": "3f1c2a9e-..."
}
| Field | Type | Required | Rules |
|---|---|---|---|
numberPlate |
string | ✅ | Not blank. Stored trimmed and in upper case. |
citizenId |
UUID | ✅ | The claiming citizen |
Response: 201 Created
{
"success": true,
"data": {
"requestId": "9b2e4c1d-...",
"status": "PENDING_VERIFICATION",
"reason": null
},
"message": "CVR Verification Requested"
}
GET /mock/cvr/verifications/{requestId}¶
Returns where a request stands: PENDING_VERIFICATION until the delay has passed, then VERIFIED or REJECTED. A rejection carries a reason, which vehicle-service passes on in its REJECT_CITIZEN_VEHICLE event.
Response: 200 OK
{
"success": true,
"data": {
"requestId": "9b2e4c1d-...",
"status": "REJECTED",
"reason": "CVR records do not show this citizen as the owner of ABC 1230"
},
"message": "CVR Verification Retrieval Successful"
}
Error Cases:
| Status | Condition |
|---|---|
404 |
No request with that id |
6.5 Fine Categories¶
Served by system-parameters-service under /system-parameters/fine-categories. A fine category is a level: a price band owned by one issuing organisation, e.g. ZRP's Level 1 ($5), Level 2 ($15), Level 3 ($30) and Escalated. The offences officers choose from sit under the levels (6.5.1 Offences). Reads need FINE_CATEGORY_READ; every write needs FINE_CATEGORY_MANAGE, which only SUPER_ADMIN holds (see Roles and Permissions.md). Levels can't be deleted, only deactivated.
Organisation scoping. Reads are scoped to the caller's organisation. A SUPER_ADMIN sees every organisation's levels and may filter by organizationId. Anyone else sees only their own organisation's levels: the organizationId filter is ignored, another organisation's level reads as 404, and a caller without an organisation gets an empty list.
ZRP's levels are seeded with fixed ids, published in Data Model.md §2.6: LEVEL_1 f1000000-0000-4000-8000-000000000001, LEVEL_2 ...0002, LEVEL_3 ...0003 and ESCALATED ...0004.
Fine category object¶
{
"id": "f1000000-0000-4000-8000-000000000001",
"code": "LEVEL_1",
"name": "Level 1",
"description": "Minor documentation gaps, minor lighting issues, safety gear non-compliance, basic cycle/pedestrian rules, simple vehicle display errors.",
"amount": 500,
"currency": "USD",
"requiresCourt": false,
"issuingOrganization": "org-uuid-...",
"isActive": true,
"deactivationReason": null,
"createdAt": "2026-03-06T10:30:00Z",
"updatedAt": "2026-03-06T10:30:00Z"
}
| Field | Description |
|---|---|
code |
Unique within the organisation. Cannot be changed. |
name |
Unique within the organisation, so two organisations can each have a "Level 1". |
amount |
Integer cents, copied onto a fine when it is issued, so later changes never alter issued fines. null exactly when requiresCourt is true. |
currency |
Code of an active currency. Copied onto fines with the amount. |
requiresCourt |
A court level (ZRP's Escalated): fines under it go to court, which sets the penalty, so it has no amount. Set at creation and cannot be changed. |
issuingOrganization |
The id of the organisation that owns the level. Only its officers may issue fines under it. Cannot be changed. |
deactivationReason |
Why the level was deactivated. null while it is active. |
GET /system-parameters/fine-categories¶
Returns a page of the levels the caller may read, active and inactive. Officer apps use it to populate the fine-issuing form.
Auth: permission FINE_CATEGORY_READ; results scoped to the caller's organisation (see above)
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
organizationId |
UUID | SUPER_ADMIN only: just this organisation's levels. Ignored for anyone else. |
page, size, sort |
See Pagination |
Response: 200 OK — a page of fine category objects. Empty for a caller without an organisation.
GET /system-parameters/fine-categories/{id}¶
Returns one fine category object.
Auth: permission FINE_CATEGORY_READ
Error Cases:
| Status | Condition |
|---|---|
404 |
Level not found, or it belongs to another organisation and the caller is not a SUPER_ADMIN |
POST /system-parameters/fine-categories¶
Creates a level for an organisation.
Auth: permission FINE_CATEGORY_MANAGE
Request Body:
{
"code": "LEVEL_1",
"name": "Level 1",
"description": "Minor offences.",
"amount": 500,
"requiresCourt": false,
"currency": "USD",
"issuingOrganization": "org-uuid-..."
}
| Field | Type | Required | Rules |
|---|---|---|---|
code |
string | ✅ | At most 255 characters. Unique within the organisation. Cannot be changed later. |
name |
string | ✅ | At most 255 characters. Unique within the organisation. |
description |
string | ❌ | At most 2000 characters |
amount |
integer | ✅ unless requiresCourt |
Cents, greater than 0. Must be left out for a court level. |
requiresCourt |
boolean | ❌ | Defaults to false. Cannot be changed later. |
currency |
string | ❌ | The code of an active currency. Defaults to USD. |
issuingOrganization |
UUID | ✅ | An existing organisation |
Response: 201 Created — the fine category object, active.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or invalid fields; amount missing on a level that does not require court, or given on one that does; or currency is not an active currency |
404 |
Organisation not found |
409 |
Code or name already exists in the organisation |
PATCH /system-parameters/fine-categories/{id}/edit¶
Changes a level. Every field is optional: a field left out is unchanged. A blank description clears it. Changing the amount or currency changes the price of every offence in the level, but does not affect fines already issued. Only name, description, amount and currency can be edited: code, issuingOrganization and requiresCourt are ignored if sent.
Auth: permission FINE_CATEGORY_MANAGE
Request Body:
{
"name": "Level 1",
"description": "Minor offences.",
"amount": 600,
"currency": "USD"
}
| Field | Rules |
|---|---|
name |
Not blank, at most 255 characters, unique within the organisation |
description |
At most 2000 characters |
amount |
Cents, greater than 0. Not allowed on a court level. |
currency |
The code of an active currency |
Response: 200 OK — the updated fine category object.
Error Cases:
| Status | Condition |
|---|---|
400 |
Invalid fields, amount sent for a court level, or currency is not an active currency |
404 |
Level not found, or outside the caller's organisation scope (writes are scoped like reads) |
409 |
Name already used by another level of the organisation |
PATCH /system-parameters/fine-categories/{id}/deactivate and /activate¶
Deactivates or activates a level. Inactive levels cannot be used to issue new fines. The level's offences keep their own active flags, so reactivating a level restores its offences as they were.
Auth: permission FINE_CATEGORY_MANAGE
Request Body (deactivate only, optional):
{
"reason": "Under review"
}
reason is at most 500 characters, stored as deactivationReason, and cleared on activate. The same optional body is accepted by every deactivate endpoint in system-parameters-service.
Response: 200 OK — the updated fine category object.
Error Cases:
| Status | Condition |
|---|---|
400 |
reason longer than 500 characters |
404 |
Level not found |
Changes are audited as CREATE_FINE_CATEGORY, UPDATE_FINE_CATEGORY, ACTIVATE_FINE_CATEGORY and DEACTIVATE_FINE_CATEGORY (entity type FineCategory).
6.5.1 Offences¶
Served by system-parameters-service under /system-parameters/offences. The offence catalogue: each offence belongs to exactly one level and takes its price, currency and issuing organisation from that level, never its own. Officers pick a level, then list the offences in it. Gated by the same permissions as levels: reads need FINE_CATEGORY_READ, every write needs FINE_CATEGORY_MANAGE (SUPER_ADMIN only). Offences can't be deleted, only deactivated.
Organisation scoping. The level rule applies through the offence's level: a SUPER_ADMIN sees every organisation's offences and may filter by organizationId; anyone else sees only offences under their own organisation's levels, and an offence under another organisation's level reads as 404.
ZRP's court catch-all is seeded under Escalated: ZRP_OTHER_COURT_OFFENCE "Other court offence", id 0ff00000-0000-4000-8000-000000000001. Use it for court matters that are not in the catalogue and describe the offence in the fine's notes.
Offence object¶
{
"id": "offence-uuid-...",
"code": "ZRP_NO_SEATBELT",
"name": "Driving without a seatbelt",
"description": "Driver or passenger not wearing a seatbelt.",
"legalReference": "SI 129 of 2015 s.4",
"fineCategoryId": "f1000000-0000-4000-8000-000000000001",
"fineCategory": {
"id": "f1000000-0000-4000-8000-000000000001",
"code": "LEVEL_1",
"name": "Level 1",
"amount": 500,
"currency": "USD",
"requiresCourt": false,
"issuingOrganization": "org-uuid-...",
"isActive": true
},
"isActive": true,
"deactivationReason": null,
"createdAt": "2026-10-27T10:30:00Z",
"updatedAt": "2026-10-27T10:30:00Z"
}
| Field | Description |
|---|---|
code |
Unique across every organisation, so integrations can rely on it. Cannot be changed. The same offence under two organisations gets two codes, e.g. ZRP_NO_SEATBELT and COH_NO_SEATBELT. |
legalReference |
The statute to cite, or null. |
fineCategoryId |
The offence's level. |
fineCategory |
A summary of the level, read live: its current amount (null for a court level), currency, requiresCourt, issuingOrganization and isActive. The offence has no amount, currency or organisation of its own. |
isActive |
The offence's own flag. Deactivating its level does not change it; check fineCategory.isActive too. |
deactivationReason |
Why the offence was deactivated. null while it is active. |
GET /system-parameters/offences¶
Returns a page of the offences the caller may read, active and inactive.
Auth: permission FINE_CATEGORY_READ; results scoped to the caller's organisation (see above)
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
fineCategoryId |
UUID | Only offences in this level. Empty for another organisation's level, unless the caller is a SUPER_ADMIN. |
organizationId |
UUID | SUPER_ADMIN only: just offences under this organisation's levels. Ignored for anyone else. |
active |
boolean | Only offences whose own isActive matches |
page, size, sort |
See Pagination |
Response: 200 OK — a page of offence objects. Empty for a caller without an organisation.
GET /system-parameters/offences/{id}¶
Returns one offence object.
Auth: permission FINE_CATEGORY_READ
Error Cases:
| Status | Condition |
|---|---|
404 |
Offence not found, or its level belongs to another organisation and the caller is not a SUPER_ADMIN |
POST /system-parameters/offences¶
Adds an offence to a level.
Auth: permission FINE_CATEGORY_MANAGE
Request Body:
{
"code": "ZRP_NO_SEATBELT",
"name": "Driving without a seatbelt",
"description": "Driver or passenger not wearing a seatbelt.",
"legalReference": "SI 129 of 2015 s.4",
"fineCategoryId": "f1000000-0000-4000-8000-000000000001"
}
| Field | Type | Required | Rules |
|---|---|---|---|
code |
string | ✅ | At most 50 characters. Unique across every organisation. Cannot be changed later. |
name |
string | ✅ | At most 255 characters |
description |
string | ❌ | At most 2000 characters |
legalReference |
string | ❌ | At most 255 characters |
fineCategoryId |
UUID | ✅ | An existing level in the caller's organisation scope (any organisation for a SUPER_ADMIN) |
Response: 201 Created — the offence object, active.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or invalid fields |
404 |
Level not found, or outside the caller's organisation scope |
409 |
Code already exists |
PATCH /system-parameters/offences/{id}/edit¶
Changes an offence. Every field is optional: a field left out is unchanged. A blank description or legalReference clears it. Moving an offence to another level gives it that level's price. code is ignored if sent.
Auth: permission FINE_CATEGORY_MANAGE
Request Body:
{
"name": "Driving without a seatbelt",
"description": "Driver or any passenger not wearing a seatbelt.",
"legalReference": "SI 129 of 2015 s.4(1)",
"fineCategoryId": "f1000000-0000-4000-8000-000000000002"
}
| Field | Rules |
|---|---|
name |
Not blank, at most 255 characters |
description |
At most 2000 characters |
legalReference |
At most 255 characters |
fineCategoryId |
Another level of the same organisation. An offence never changes organisation, since codes are per organisation (e.g. ZRP_...). |
Response: 200 OK — the updated offence object.
Error Cases:
| Status | Condition |
|---|---|
400 |
Invalid fields, or fineCategoryId is a level of another organisation |
404 |
Offence or level not found, or outside the caller's organisation scope |
PATCH /system-parameters/offences/{id}/deactivate and /activate¶
Deactivates or activates an offence. Inactive offences cannot be used to issue new fines. Takes the same optional {"reason": "..."} body as deactivating a level: at most 500 characters, stored as deactivationReason, cleared on activate.
Auth: permission FINE_CATEGORY_MANAGE
Response: 200 OK — the updated offence object.
Error Cases:
| Status | Condition |
|---|---|
400 |
reason longer than 500 characters |
404 |
Offence not found |
Changes are audited as CREATE_OFFENCE, UPDATE_OFFENCE, ACTIVATE_OFFENCE and DEACTIVATE_OFFENCE (entity type Offence).
6.6 Fines¶
Served by payments-service under /fines, gated by the permissions FINE_CREATE, FINE_READ and FINE_STATUS_UPDATE (see Roles and Permissions.md). Disputes are in §6.6.1.
Fine object¶
Issue, read, search and status change all return this shape:
{
"id": "fine-uuid-...",
"referenceNumber": "TG-2026-00123",
"vehicle": {
"id": "7c9e6679-...",
"numberPlate": "ABC 1234",
"make": "Toyota",
"model": "Hilux"
},
"fineCategory": {
"id": "cat-uuid-...",
"code": "SPEEDING_01",
"name": "Exceeding Speed Limit"
},
"issuedBy": {
"id": "officer-uuid-...",
"fullName": "Const. J. Dube",
"organisationId": "org-uuid-...",
"organisation": "ZRP"
},
"amount": 20000,
"currency": "USD",
"status": "PAID",
"location": "Corner Samora Machel Ave & Julius Nyerere Way, Harare",
"notes": "Vehicle observed doing 95km/h in a 60km/h zone.",
"statusChangeReason": null,
"dueDate": "2026-04-06T10:30:00Z",
"issuedAt": "2026-03-06T10:30:00Z",
"createdAt": "2026-03-06T10:30:00Z",
"updatedAt": "2026-03-07T08:12:40Z",
"vehicleId": "7c9e6679-...",
"fineCategoryId": "cat-uuid-...",
"issuingOrganization": "org-uuid-...",
"payment": {
"id": "payment-uuid-...",
"referenceNumber": "TGPAY-2026-00789",
"paymentMethod": "ECOCASH",
"paidAt": "2026-03-07T08:12:39Z"
},
"dispute": null
}
| Field | Description |
|---|---|
vehicle, fineCategory, issuedBy |
Snapshots taken when the fine was issued, never looked up on read: the vehicle's plate, make and model; the category's code and name; the officer's name; the issuing organisation's short name. issuedBy.organisation is null if the organisation could not be looked up at issue time (the fine is still issued). |
amount, currency |
Copied from the category at issue time, in cents. Unaffected by later category changes. |
dueDate |
An ISO 8601 timestamp |
issuedAt |
Equal to createdAt |
statusChangeReason |
The reason given with the latest manual status change, or the note of a dispute decision, or null. Filing a dispute clears it. |
vehicleId, fineCategoryId, issuingOrganization |
Flat copies of the nested ids, kept for older clients |
payment |
The SUCCESS payment that settled the fine, or null |
dispute |
The fine's dispute, or null if it has never been disputed. A fine has at most one. |
POST /fines¶
Issues a new fine against a vehicle. The amount and currency come from the fine category — the officer cannot set them.
Auth: permission FINE_CREATE. The caller's organisation must be the category's issuing organisation, except for a super admin, who issues on behalf of the category's organisation.
Request Body:
{
"vehicleId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"fineCategoryId": "cat-uuid-...",
"location": "Corner Samora Machel Ave & Julius Nyerere Way, Harare",
"notes": "Vehicle observed doing 95km/h in a 60km/h zone.",
"dueDate": "2026-04-06T10:30:00Z"
}
| Field | Type | Required | Rules |
|---|---|---|---|
vehicleId |
UUID | ✅ | Vehicle must exist in vehicle-service |
fineCategoryId |
UUID | ✅ | Level (fine category) must exist, be readable by the caller and be active |
location |
string | ❌ | Where the offence occurred |
notes |
string | ❌ | Officer notes |
dueDate |
timestamp | ❌ | Defaults to 30 days after issue |
Response: 201 Created — the fine object, PENDING, with payment: null.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or malformed fields |
404 |
Vehicle or fine category not found. payments-service reads the category as the caller, so another organisation's level is also 404 for anyone but a SUPER_ADMIN. |
422 |
Fine category is inactive, or the caller's organisation does not issue this category |
GET /fines/{id}¶
Returns one fine object.
Auth: permission FINE_READ
A
CITIZENreads only fines on their own verified vehicles (see 6.4 Citizen Vehicles). payments-service asks vehicle-service for the caller's links and keeps onlyVERIFIEDones, so a fine on a vehicle whose claim is still pending is hidden like any other citizen's fine. If vehicle-service can't answer, the request fails with503. Every other role reads all fines.
Error Cases:
| Status | Condition |
|---|---|
400 |
id is not a UUID |
404 |
Fine not found, or (for a citizen) not on one of their verified vehicles |
503 |
Citizen only: their vehicles could not be checked with vehicle-service |
GET /fines¶
Searches and filters fines. Officers and admins use this for reporting and case management.
Auth: permission FINE_READ (the citizen scoping under GET /fines/{id} applies here too)
Query Parameters: all optional, combined with AND.
| Parameter | Type | Description |
|---|---|---|
q |
string | Part of the reference number, or part of the plate ignoring spaces and case |
plate |
string | Part of the plate, ignoring spaces and case |
referenceNumber |
string | The exact reference number, ignoring case |
vehicleId |
UUID | Filter by vehicle |
issuedBy |
UUID | Filter by officer |
organisationId |
UUID | Filter by issuing organisation (organizationId is accepted too) |
fineCategoryId |
UUID | Filter by category |
status |
FineStatus |
Filter by status |
from |
timestamp | Issued at or after (ISO 8601 instant) |
to |
timestamp | Issued at or before (ISO 8601 instant) |
page, size |
integer | See Pagination |
sort |
string | Default createdAt,desc |
Response: 200 OK — a page of fine objects plus statusCounts:
{
"success": true,
"data": {
"content": [ { "id": "fine-uuid-...", "referenceNumber": "TG-2026-00123", "status": "PENDING" } ],
"page": 1,
"size": 20,
"totalElements": 87,
"totalPages": 5,
"first": true,
"last": false,
"statusCounts": {
"PENDING": 61,
"PAID": 20,
"CLEARED": 2,
"DISPUTED": 3,
"CANCELLED": 1
}
},
"message": "Fines retrieved"
}
statusCounts has an entry for every FineStatus, counted with every filter except status, so a client can label status tabs from one request. Each row in content is the full fine object (shortened above).
PATCH /fines/{id}/status¶
Changes a fine's status with a reason. Used by admins to clear or cancel fines. A fine's DISPUTED status is set and left only through its dispute.
Auth: permission FINE_STATUS_UPDATE. The caller's organisation must be the fine's issuing organisation, except for a super admin.
Request Body:
{
"status": "CLEARED",
"reason": "Duplicate entry — original fine reference TG-2026-00120"
}
| Field | Type | Required | Rules |
|---|---|---|---|
status |
FineStatus |
✅ | An allowed transition (below). Never PAID — payments set that — and never DISPUTED. |
reason |
string | ✅ | Not blank, at most 500 characters. Returned as statusChangeReason. |
Allowed transitions: PENDING → CLEARED or CANCELLED. PAID, CLEARED and CANCELLED are final. A DISPUTED fine can't be changed here: its dispute is withdrawn or decided instead.
Response: 200 OK — the updated fine object.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or unknown status, or missing, blank or too-long reason |
404 |
Fine not found |
422 |
status is PAID or DISPUTED, the fine is DISPUTED, the transition is not allowed, or the fine belongs to another organisation |
6.6.1 Fine Disputes¶
Served by payments-service under /fines. A citizen disputes a PENDING fine on one of their linked vehicles; the fine becomes DISPUTED and can't be paid until the dispute is withdrawn or decided. An admin of the fine's issuing organisation, or a super admin, then upholds the dispute (the fine is CLEARED) or rejects it (the fine is PENDING again). A fine can be disputed once, ever: a withdrawn or decided dispute still counts. Filing, withdrawing and deciding need FINE_DISPUTE_CREATE or FINE_DISPUTE_REVIEW (see Roles and Permissions.md).
Evidence is described, not uploaded: only each file's name, type and size are kept. There is no file storage yet.
Dispute object¶
The dispute field of a fine object:
{
"id": "dispute-uuid-...",
"grounds": "INCORRECT_DETAILS",
"statement": "I was parked at home in Borrowdale at the time of this fine.",
"evidence": [
{ "id": "evidence-uuid-...", "fileName": "parking-receipt.jpg", "contentType": "image/jpeg", "sizeBytes": 482113 }
],
"status": "REJECTED",
"filedAt": "2026-03-10T09:00:00Z",
"filedByName": "Tendai Moyo",
"decidedAt": "2026-04-02T14:20:00Z",
"decisionNote": "Speed camera record confirms the vehicle at the stated location.",
"reviewingOrganisation": "ZRP",
"reviewedByName": "Insp. R. Ncube"
}
| Field | Description |
|---|---|
grounds |
NOT_MY_VEHICLE, INCORRECT_DETAILS, DID_NOT_COMMIT, ALREADY_PAID or OTHER |
status |
OPEN, then UPHELD, REJECTED or WITHDRAWN, which are final |
filedByName, reviewedByName |
Name snapshots of the citizen who filed and the admin who decided. Always null for a CITIZEN caller, who sees only the reviewing organisation. filedByName is also null for disputes created by the V16 backfill (fines marked DISPUTED before disputes existed). |
decidedAt |
When the dispute was decided or withdrawn; null while OPEN |
decisionNote |
The reviewer's note; null until decided |
reviewingOrganisation |
Short name of the fine's issuing organisation, which the reviewer acts for (a super admin included); null until decided |
POST /fines/{id}/dispute¶
Files a dispute against a fine. The fine becomes DISPUTED. Its due date is unchanged, so filing never buys time to pay, and its previous statusChangeReason is cleared.
Auth: role CITIZEN and permission FINE_DISPUTE_CREATE. The fine must be on one of the caller's verified vehicles: a vehicle whose claim CVR is still verifying doesn't count (see 6.4 Citizen Vehicles).
Request Body:
{
"grounds": "INCORRECT_DETAILS",
"statement": "I was parked at home in Borrowdale at the time of this fine.",
"evidence": [
{ "fileName": "parking-receipt.jpg", "contentType": "image/jpeg", "sizeBytes": 482113 }
]
}
| Field | Type | Required | Rules |
|---|---|---|---|
grounds |
string | ✅ | One of the grounds above |
statement |
string | ✅ | Not blank, at most 1000 characters |
evidence |
array | ❌ | At most 3 items |
evidence[].fileName |
string | ✅ | Not blank, at most 255 characters |
evidence[].contentType |
string | ✅ | image/jpeg, image/png or application/pdf |
evidence[].sizeBytes |
integer | ✅ | Positive, at most 5 MB (5242880) |
Response: 201 Created — the dispute object, OPEN.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or invalid fields |
404 |
Fine not found, or not on one of the caller's verified vehicles (the same answer either way, so a fine's existence isn't revealed) |
422 |
The fine is not PENDING, is past its due date, has already been disputed, or is in a payment that is PENDING or SUCCESS (not yet settled) |
POST /fines/{id}/dispute/withdraw¶
Withdraws the caller's OPEN dispute. The dispute becomes WITHDRAWN (with decidedAt set) and the fine PENDING, due date unchanged. The fine still can't be disputed again. No request body.
Auth: role CITIZEN and permission FINE_DISPUTE_CREATE. Only the citizen who filed the dispute, whether or not the vehicle is still linked to them.
Response: 200 OK — the dispute object, WITHDRAWN.
Error Cases:
| Status | Condition |
|---|---|
404 |
Fine not found, it has no dispute, or the caller didn't file it |
422 |
The dispute is not OPEN, or the fine is not DISPUTED |
POST /fines/{id}/dispute/decision¶
Upholds or rejects an OPEN dispute, with a note. The note becomes the fine's statusChangeReason, and the reviewer, decision time and reviewing organisation (the fine's issuing organisation) are recorded.
UPHELD: the fine becomesCLEARED.REJECTED: the fine becomesPENDING, and its due date becomes the later of the original due date and 7 days after the decision. A decision more than 7 days before the due date leaves it unchanged; a decision in the last 7 days, or after the due date, gives exactly 7 days from the decision. The 7 days is fixed, not a system parameter.
Auth: permission FINE_DISPUTE_REVIEW. The caller's organisation must be the fine's issuing organisation, except for a super admin.
Request Body:
{
"outcome": "REJECTED",
"note": "Speed camera record confirms the vehicle at the stated location."
}
| Field | Type | Required | Rules |
|---|---|---|---|
outcome |
string | ✅ | UPHELD or REJECTED |
note |
string | ✅ | Not blank, at most 500 characters |
Response: 200 OK — the updated fine object, with its dispute.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or invalid fields |
404 |
Fine not found, or it has no dispute |
422 |
The fine belongs to another organisation, the dispute is not OPEN, or the fine is not DISPUTED |
GET /fines/disputes¶
The review queue: a page of disputed fines, each a fine object carrying its dispute. Limited to the caller's organisation's fines, except for a super admin, who sees every organisation's.
Auth: permission FINE_DISPUTE_REVIEW
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
status |
string | Dispute status: OPEN (default), UPHELD, REJECTED or WITHDRAWN |
page, size |
integer | See Pagination |
sort |
string | filedAt (default, oldest first) or decidedAt. Any other field is a 400. |
Response: 200 OK — the standard paginated response of fine objects (one-based page, no statusCounts).
Error Cases:
| Status | Condition |
|---|---|
400 |
Unknown status or unsupported sort field |
6.7 Parking Tickets¶
⚠️ Status: Planned — pending City Parking API integration.
POST /parking-tickets¶
Issues a new parking ticket.
Auth: PARKING
Request Body:
{
"vehicleId": "7c9e6679-...",
"location": "First Street Car Park, Bay 14, Harare CBD",
"amount": 500,
"dueDate": "2026-03-13",
"externalReference": "CP-2026-98765"
}
Response: 201 Created
{
"success": true,
"data": {
"id": "ticket-uuid-...",
"referenceNumber": "PKT-2026-00456",
"vehicle": {
"id": "7c9e6679-...",
"numberPlate": "ABC 1234"
},
"issuedBy": {
"id": "officer-uuid-...",
"fullName": "P. Ncube"
},
"location": "First Street Car Park, Bay 14, Harare CBD",
"amount": 500,
"status": "PENDING",
"externalReference": "CP-2026-98765",
"dueDate": "2026-03-13",
"issuedAt": "2026-03-06T10:30:00Z"
},
"message": null
}
GET /parking-tickets/{id}¶
Returns details for a single parking ticket.
Auth: PARKING, SUPER, AUTH_ADMIN, CITIZEN (own linked vehicles only)
Response: 200 OK
{
"success": true,
"data": {
"id": "ticket-uuid-...",
"referenceNumber": "PKT-2026-00456",
"vehicle": {
"id": "7c9e6679-...",
"numberPlate": "ABC 1234"
},
"issuedBy": {
"id": "officer-uuid-...",
"fullName": "P. Ncube"
},
"location": "First Street Car Park, Bay 14, Harare CBD",
"amount": 500,
"status": "PENDING",
"externalReference": "CP-2026-98765",
"dueDate": "2026-03-13",
"issuedAt": "2026-03-06T10:30:00Z"
},
"message": null
}
GET /parking-tickets¶
Search and filter parking tickets.
Auth: PARKING, SUPER, AUTH_ADMIN
Query Parameters: vehicleId, status, from, to, page, size, sort
Response: 200 OK
{
"success": true,
"data": {
"content": [
{
"id": "ticket-uuid-...",
"referenceNumber": "PKT-2026-00456",
"vehicle": {
"id": "7c9e6679-...",
"numberPlate": "ABC 1234"
},
"amount": 500,
"status": "PENDING",
"dueDate": "2026-03-13",
"issuedAt": "2026-03-06T10:30:00Z"
}
],
"page": 0,
"size": 20,
"totalElements": 12,
"totalPages": 1,
"first": true,
"last": true
},
"message": null
}
6.8 Payments¶
Served by payments-service under /payments, gated by the permissions PAYMENT_CREATE and PAYMENT_READ (see Roles and Permissions.md).
Payment object¶
Create, read, search and the mock callback all return this shape:
{
"id": "payment-uuid-...",
"referenceNumber": "TGPAY-2026-00789",
"paymentMethod": "CASH",
"amount": 25000,
"currency": "USD",
"status": "SUCCESS",
"paidBy": { "id": null, "fullName": "Tendai Moyo" },
"recordedBy": { "id": "officer-uuid-...", "fullName": "Const. J. Dube" },
"providerReference": null,
"idempotencyKey": "client-generated-unique-key-abc123",
"items": [
{
"id": "item-uuid-...",
"payableType": "FINE",
"payableId": "fine-uuid-...",
"reference": "TG-2026-00123",
"amount": 20000,
"offenceName": "Exceeding the speed limit",
"vehicleNumberPlate": "AEB 4471",
"vehicleId": "vehicle-uuid-...",
"fineStatus": "PAID"
},
{
"id": "item-uuid-2-...",
"payableType": "FINE",
"payableId": "fine-uuid-2-...",
"reference": "TG-2026-00124",
"amount": 5000,
"offenceName": "Failing to stop at a red light",
"vehicleNumberPlate": "AEB 4471",
"vehicleId": "vehicle-uuid-...",
"fineStatus": "PAID"
}
],
"details": {
"payer": { "fullName": "Tendai Moyo", "phoneNumber": null },
"provider": null,
"cashReceipt": {
"receiptNumber": "TGRCT-2026-00042",
"location": "Harare Central payment point",
"recordedBy": { "id": "officer-uuid-...", "fullName": "Const. J. Dube" }
},
"bankTransfer": null
},
"paidAt": "2026-03-06T10:30:00Z",
"createdAt": "2026-03-06T10:30:00Z",
"updatedAt": "2026-03-06T10:30:00Z"
}
| Field | Description |
|---|---|
referenceNumber |
TGPAY-YYYY-NNNNN |
amount |
The sum of the items, in cents |
currency |
The items' currency (all items in one payment share it) |
paidBy |
Who paid. For ECOCASH and BANK_TRANSFER, the signed-in caller (id and name). For CASH, the walk-in payer the officer named: id is null. |
recordedBy |
For CASH, the officer who recorded it. null otherwise. |
providerReference |
The provider's reference, set when an ECOCASH payment is sent to the (mock) provider |
items[].reference |
The payable's reference number (a fine's referenceNumber), snapshotted when the payment was created |
items[].offenceName, items[].vehicleNumberPlate, items[].vehicleId |
Read from the fine each time the payment is read, so a receipt can show them without fetching each fine. null for other payable types. |
items[].fineStatus |
The fine's live status. Fines are settled to PAID asynchronously after the payment reaches SUCCESS, so this can briefly lag. Show a fine as paid from the payment's status, not this field. |
details.payer |
The payer's name and phone number as given (the name defaults to the caller's) |
details.provider |
{ transactionId, statusMessage, callbackAt } once the provider has called back; null before |
details.cashReceipt |
CASH only: { receiptNumber, location, recordedBy }; otherwise null |
details.bankTransfer |
BANK_TRANSFER only: { bankName, accountReference, proofReference }; otherwise null |
POST /payments¶
Starts a payment for one or more fines. Citizens pay their own fines online; officers record cash payments.
Auth: permission PAYMENT_CREATE
Request Body:
{
"paymentMethod": "ECOCASH",
"idempotencyKey": "client-generated-unique-key-abc123",
"items": [
{ "payableType": "FINE", "payableId": "fine-uuid-...", "amount": 20000 },
{ "payableType": "FINE", "payableId": "fine-uuid-2-...", "amount": 5000 }
],
"payer": { "fullName": "Tendai Moyo", "phoneNumber": "+263772345678" },
"cashReceipt": { "receiptNumber": "TGRCT-2026-00042", "location": "Harare Central payment point" },
"bankTransfer": { "bankName": "CBZ", "accountReference": "TG-COLLECTIONS", "proofReference": "CBZ-889123" }
}
| Field | Type | Required | Rules |
|---|---|---|---|
paymentMethod |
ECOCASH | BANK_TRANSFER | CASH |
✅ | |
idempotencyKey |
string | ✅ | Unique per payment attempt. Generated by the client. |
items |
array | ✅ | At least one. paymentItems is accepted as an alias. |
items[].payableType |
PayableType |
✅ | FINE (parking tickets are not payable yet) |
items[].payableId |
UUID | ✅ | A PENDING fine with no open or successful payment. Each fine at most once. |
items[].amount |
integer | ✅ | Cents. Must equal the fine's amount. |
payer.fullName |
string | CASH |
At most 255 characters. Required for CASH (the walk-in payer); otherwise defaults to the caller's name. |
payer.phoneNumber |
string | ECOCASH |
At most 50 characters. Required for ECOCASH. |
cashReceipt.receiptNumber |
string | ❌ | CASH only. Generated as TGRCT-YYYY-NNNNN when left out. |
cashReceipt.location |
string | ❌ | CASH only. Where the cash was taken. |
bankTransfer.bankName |
string | BANK_TRANSFER |
Required for BANK_TRANSFER |
bankTransfer.accountReference |
string | BANK_TRANSFER |
Required for BANK_TRANSFER |
bankTransfer.proofReference |
string | ❌ | The transfer's proof-of-payment reference |
A citizen finds the fines they can pay through GET /citizens/me/fines and GET /fines/{id}, which only show fines on their verified vehicles, so a fine on a vehicle still being verified is never offered for payment. POST /payments itself does not check who owns the fine's vehicle: anyone may pay a fine they can name.
A CASH payment is SUCCESS at once (paidAt set). ECOCASH and BANK_TRANSFER start PENDING; an ECOCASH payment is sent to the mock EcoCash provider, which sets providerReference and settles it a few seconds later. When a payment succeeds, its fines are marked PAID asynchronously (see Kafka Design.md).
Retrying safely. Generate one idempotencyKey per payment attempt. If the request fails with a network error, a timeout or a 5xx, resend it with the same key (with exponential backoff and jitter, see Failure Handling.md): if the first request did get through, the retry returns 409 with that payment in data, which the client treats as success. Don't retry other 4xx responses. To try again after a payment ends FAILED, start a new attempt with a new key; a failed payment no longer holds its fines.
The mobile app shows a flat service fee of USD 0.50 (converted at the currency's rate for non-USD fines) before payment. It is display-only: it is not sent, not part of amount, and not stored.
Response: 201 Created — the payment object.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or invalid fields, empty items, the same fine twice, a cash payment whose fines belong to different citizens, or what the method needs is missing (payer.phoneNumber for ECOCASH, payer.fullName for CASH, bankTransfer.bankName and bankTransfer.accountReference for BANK_TRANSFER) |
404 |
Referenced fine not found (a PARKING_TICKET item is always 404 for now) |
409 |
Idempotency key already used — data is the existing payment |
422 |
A fine is not PENDING, already has a payment in progress, or its amount does not match; or the items are in different currencies |
GET /payments/{id} and GET /payments/reference/{referenceNumber}¶
Returns one payment object, by id or by reference number.
Auth: permission PAYMENT_READ. ZRP_OFFICER, COUNCIL_OFFICER, PARKING_OFFICER, AUTHORITY_ADMIN and SUPER_ADMIN can read any payment; anyone else only payments whose paidBy.id is their own. A CASH payment has no payer id, so only those roles can read it.
Error Cases:
| Status | Condition |
|---|---|
400 |
id is not a UUID |
404 |
Payment not found, or not visible to the caller (its existence is not revealed) |
GET /payments¶
Searches payments.
Auth: permission PAYMENT_READ, with the same visibility as GET /payments/{id}: for callers outside the view-all roles, paidBy is forced to themselves.
Query Parameters: all optional, combined with AND.
| Parameter | Type | Description |
|---|---|---|
q |
string | Part of the reference number, provider reference, payer name or any item's fine reference, ignoring case |
status |
PaymentStatus |
Filter by status |
paymentMethod |
PaymentMethod |
Filter by method |
paidBy |
UUID | Filter by payer |
organisationId |
UUID | Payments with any item whose fine was issued by this organisation (organizationId is accepted too) |
from |
timestamp | Created at or after (ISO 8601 instant) |
to |
timestamp | Created at or before (ISO 8601 instant) |
page, size |
integer | See Pagination |
sort |
string | One of referenceNumber, amount, createdAt, paidAt. Default createdAt,desc. |
Response: 200 OK — a page of payment objects plus statusCounts, one entry per PaymentStatus (PENDING, SUCCESS, FAILED, REVERSED) counted with every filter except status — the same shape as GET /fines.
Error Cases:
| Status | Condition |
|---|---|
400 |
sort names another field |
POST /payments/mock/{referenceNumber}/callback¶
Temporary: stands in for the EcoCash webhook until the real provider integration exists. Moves a PENDING payment to SUCCESS (setting paidAt) or FAILED.
Auth: permission PAYMENT_CREATE
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
success |
boolean | ✅ | true for SUCCESS, false for FAILED |
transactionId |
string | ❌ | The provider's transaction id |
message |
string | ❌ | The provider's status message |
transactionId, message and the callback time are returned under details.provider.
Response: 200 OK — the updated payment object.
Error Cases:
| Status | Condition |
|---|---|
400 |
success missing or not a boolean |
404 |
No payment with that reference |
422 |
The payment is no longer PENDING (for example, the mock EcoCash provider settled it first) |
Mock EcoCash provider¶
Temporary, until the real EcoCash integration and webhook exist. payments-service settles ECOCASH payments by itself, so a citizen's payment completes without anyone calling the mock callback:
- Every
trafficgrid.ecocash.poll-interval(ECOCASH_POLL_INTERVAL, defaultPT1S) it findsECOCASHpayments that have beenPENDINGfor longer thantrafficgrid.ecocash.confirmation-delay(ECOCASH_CONFIRMATION_DELAY, defaultPT3S). - It settles each one exactly as the mock callback does, with a
MOCK-TXN-…transaction id and the messageApprovedorDeclined. A success publishesSUCCEED_PAYMENT, which settles the fines toPAID. - A payer phone ending in
0000is declined while the payment's fines have fewer thantrafficgrid.ecocash.mock-failures-before-success(ECOCASH_MOCK_FAILURES_BEFORE_SUCCESS, default2) earlierFAILEDpayments, then approved. With the defaults, attempts 1 and 2 fail and attempt 3 succeeds, so the failure-and-retry flow can be tried. Every other phone is approved straight away. - Settling takes a row lock on the payment, so the poller, the mock callback and other instances never settle the same payment twice; whichever comes second sees it is no longer
PENDING. - The poller only runs when
trafficgrid.ecocash.mock-enabled(ECOCASH_MOCK_ENABLED) istrue. It defaults tofalse; the dev Docker stack turns it on.trafficgrid.ecocash.mock-outcome(ECOCASH_MOCK_OUTCOME) isAUTO(the0000rule above, the default),SUCCESSorFAILEDfor every payment. At startup it logsMock EcoCash enabled: confirms ECOCASH payments after PT3S, outcome AUTO, and each settlement logsMock EcoCash settled payment TGPAY-… as SUCCESS.
The integration suite drives outcomes through the mock callback, so a live run sets ECOCASH_CONFIRMATION_DELAY=PT10M (see the integration-tests README).
POST /payments/webhook¶
⚠️ Status: Planned — the mock callback above stands in for it today.
Will receive payment status callbacks from EcoCash or other payment providers, verified against a shared secret rather than a Bearer token, and update the payment the same way the mock callback does.
6.9 Notifications¶
Served by notification-service under /api/v1/notifications, routed by the gateway like every other service (the old /notification-service/** prefix route is gone).
A notification is a stored record of one event for one citizen. notification-service creates them from the events the other services publish on Kafka (see Kafka Design.md §3 for which events notify whom), pushes each one to the citizen's registered phones, and lists them all here, whatever the push outcome. Nothing in this API creates a notification.
Auth: every endpoint is for role CITIZEN only; any other role gets 403. The user is always the caller, taken from the gateway's identity headers. The old userId query parameter is removed: it is ignored if sent, so a caller can only ever read and change their own notifications, devices and preferences.
Notification object¶
{
"id": "018e1560-4821-4000-8000-000000000001",
"type": "FINE_ISSUED",
"category": "FINES",
"title": "New fine issued",
"body": "A fine of USD 20.00 for Speeding has been issued against ABC 1234 (TG-20260306-0001). Pay by 6 April 2026.",
"payload": {
"fineId": "018e1560-4821-4000-8000-000000000002",
"referenceNumber": "TG-20260306-0001",
"numberPlate": "ABC 1234",
"dueDate": "2026-04-06T10:30:00Z",
"amount": 2000,
"currency": "USD"
},
"createdAt": "2026-03-06T10:30:01Z",
"readAt": null
}
| Field | Meaning |
|---|---|
type |
What happened; see the types below |
category |
FINES, PAYMENTS or VEHICLES, derived from type |
title, body |
Plain-English text built by notification-service from the event, with money formatted from integer cents and currency, and dates as the calendar day in Zimbabwe |
payload |
Deep-link target and display data; its keys depend on the type (below) |
createdAt |
When the notification was recorded |
readAt |
When the citizen read it; null while unread |
| Type | Category | payload keys |
|---|---|---|
VEHICLE_VERIFIED, VEHICLE_VERIFICATION_FAILED |
VEHICLES |
vehicleId, linkId, numberPlate |
FINE_ISSUED, FINE_PAID, FINE_DISPUTE_DECIDED, FINE_COURT_OUTCOME, FINE_STATUS_CHANGED |
FINES |
fineId, referenceNumber, numberPlate; plus amount and currency when the text names an amount, and dueDate when it names a due date |
PAYMENT_SUCCESS, PAYMENT_FAILED |
PAYMENTS |
paymentId, referenceNumber, fineIds (the fines the payment covers) |
DOCUMENT_EXPIRY_REMINDER (VEHICLES) and FINE_DUE_REMINDER (FINES) exist but nothing produces them yet.
GET /notifications/me¶
Returns the caller's notifications, newest first.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
category |
FINES | PAYMENTS | VEHICLES |
Only notifications in this category |
page |
integer | Zero-based, default 0 |
size |
integer | Default 20, at most 100 |
Response: 200 OK
{
"success": true,
"data": {
"content": [ { "id": "018e1560-...", "type": "FINE_ISSUED", "category": "FINES", "title": "New fine issued" } ],
"page": 1,
"size": 20,
"totalElements": 14,
"totalPages": 1,
"first": true,
"last": true
},
"message": "Notifications Retrieval Successful"
}
Each row in content is the full notification object (shortened above).
Error Cases:
| Status | Condition |
|---|---|
400 |
Unknown category, negative page, or size outside 1–100 |
GET /notifications/me/unread-count¶
Returns how many of the caller's notifications are unread. The app shows it on the Alerts tab and the app icon.
Response: 200 OK
{
"success": true,
"data": { "count": 3 },
"message": "Unread Count Retrieval Successful"
}
PATCH /notifications/{id}/read¶
Marks one of the caller's notifications read. Calling it again is harmless and keeps the original readAt. No request body.
Response: 200 OK — the updated notification object.
Error Cases:
| Status | Condition |
|---|---|
404 |
No such notification for the caller, including another user's notification (its existence is not revealed) |
POST /notifications/me/read-all¶
Marks all of the caller's notifications read, so they stay read on the citizen's other phones and after a reinstall. No request body.
Response: 200 OK — the new unread count, { "count": 0 }.
POST /notifications/me/devices¶
Registers the caller's phone for push notifications, or refreshes it. The app calls it after sign-in and on every app start while signed in. A citizen can have several devices; each gets every push. A token already registered to another user moves to the caller.
Request Body:
{
"expoPushToken": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
"platform": "android"
}
| Field | Type | Required | Rules |
|---|---|---|---|
expoPushToken |
string | ✅ | The Expo push token from the app. Not blank, at most 255 characters. |
platform |
ANDROID | IOS |
✅ | Any case (android is accepted) |
Response: 200 OK
{
"success": true,
"data": {
"expoPushToken": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
"platform": "ANDROID",
"lastSeenAt": "2026-03-06T10:30:00Z"
},
"message": "Device Registered"
}
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or blank token, or an unknown platform |
DELETE /notifications/me/devices/{expoPushToken}¶
Unregisters the caller's phone, so it stops receiving their pushes. The app calls it on sign-out, before clearing the session. It succeeds even when the caller no longer holds the token (already removed, or taken over by another user on the same phone); another user's token is left alone.
Response: 200 OK — data is null.
GET /notifications/preferences¶
Returns the caller's notification channel preferences, creating them with SMS and EMAIL on first read.
Kept, but not used when sending. No preference is consulted when a notification is created or pushed: every notification is recorded and pushed to every registered device. Per-channel and per-type preferences are future work.
Response: 200 OK
{
"success": true,
"data": {
"id": "pref-uuid-...",
"channels": ["EMAIL", "SMS"],
"updatedAt": "2026-03-06T10:30:00Z"
},
"message": "Preferences Retrieval Successful"
}
PUT /notifications/preferences¶
Replaces the caller's notification channel preferences entirely. Not used when sending (see above).
Request Body:
{
"channels": ["SMS", "PUSH"]
}
Response: 200 OK
{
"success": true,
"data": {
"id": "pref-uuid-...",
"channels": ["PUSH", "SMS"],
"updatedAt": "2026-03-06T11:00:00Z"
},
"message": "Preferences Updated"
}
Error Cases:
| Status | Condition |
|---|---|
400 |
Empty or missing channels, or a channel other than SMS, EMAIL or PUSH |
6.10 Admin — Users¶
Served by auth-service under /users. Each endpoint is gated by a permission code (see Roles and Permissions.md), not a role list.
User object¶
Every endpoint below that returns a user returns this shape:
{
"userId": "user-uuid-...",
"fullName": "Const. John Dube",
"email": "j.dube@zrp.gov.zw",
"phoneNumber": "+263772345678",
"nationalId": "63-234567-B-22",
"roleId": "role-uuid-...",
"roleName": "ZRP_OFFICER",
"organizationId": "org-uuid-...",
"isActive": true,
"deactivationReason": null,
"createdAt": "2026-03-06T10:30:00Z"
}
| Field | Description |
|---|---|
roleName |
The bare role name, never ROLE_-prefixed |
organizationId |
The organisation's id in system-parameters-service. null for users who belong to none. auth-service stores only the id; read the organisation itself from §6.11. |
deactivationReason |
Why the account was deactivated. null while it is active. |
POST /users¶
Creates an officer or admin account. Citizens self-register through /auth/register.
Auth: permission USER_CREATE
Service: auth-service
Request Body:
{
"fullName": "Const. John Dube",
"email": "j.dube@zrp.gov.zw",
"phoneNumber": "+263772345678",
"nationalId": "63-234567-B-22",
"roleId": "role-uuid-...",
"organizationId": "org-uuid-..."
}
| Field | Type | Required | Rules |
|---|---|---|---|
fullName |
string | ✅ | |
email |
string | ✅ | Must be unique |
phoneNumber |
string | ✅ | Must be unique |
nationalId |
string | ✅ | Must be unique |
roleId |
UUID | ✅ | A role from GET /roles |
organizationId |
UUID | ✅ | Stored on the user and carried in their access token |
password |
string | ❌ | Min 8 characters. Leave it out: the user is then sent a one-time link (valid three days) to choose their own, so no admin ever knows it. See /auth/forgot-password — until email delivery exists, the link is logged. |
Response: 201 Created — the user object.
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or invalid fields |
404 |
Role not found |
409 |
Email, phone, or national ID already registered |
GET /users¶
Returns a paginated list of users.
Auth: permission USER_LIST (an admin-only code; GET /users/{id} stays on USER_READ)
Service: auth-service
Query Parameters: all optional, combined with AND.
| Parameter | Type | Description |
|---|---|---|
roleId |
UUID | Users with this role |
organizationId |
UUID | Users in this organisation |
isActive |
boolean | Active or deactivated users |
q |
string | Part of the name, email, phone number or national ID, ignoring case |
page |
integer | Zero-based page number |
size |
integer | Items per page |
sort |
string | Spring sort, e.g. createdAt,desc or fullName,asc |
Response: 200 OK — a page of user objects in data.content, with totalElements, totalPages, number, size, first and last.
GET /users/{id} and GET /users/me¶
Returns one user object: by id (permission USER_READ), or the caller's own (any authenticated user).
Error Cases:
| Status | Condition |
|---|---|
404 |
User not found |
PATCH /users/{id}/deactivate¶
Deactivates an account and records why. The user can no longer sign in, and their next token refresh is refused with 401, so their access ends within one access-token lifetime.
Auth: permission USER_DEACTIVATE
Service: auth-service
Request Body:
{
"reason": "Officer suspended pending investigation"
}
| Field | Type | Required | Rules |
|---|---|---|---|
reason |
string | ✅ | At most 500 characters. Returned as deactivationReason and included in the UPDATE_USER_STATUS audit event. |
Response: 204 No Content
Error Cases:
| Status | Condition |
|---|---|
400 |
Missing or too-long reason |
404 |
User not found |
PATCH /users/{id}/activate¶
Activates an account again and clears its deactivationReason.
Auth: permission USER_ACTIVATE
Service: auth-service
Request Body: None
Response: 204 No Content
Error Cases:
| Status | Condition |
|---|---|
404 |
User not found |
Fine-category administration (create, edit, activate, deactivate) is in 6.5 Fine Categories.
6.11 Admin — Organisations¶
Served by system-parameters-service under /system-parameters/organizations (spelled with a z). Reads need ORGANIZATION_READ or ORGANIZATION_MANAGE; writes need ORGANIZATION_MANAGE.
Organisation object¶
{
"id": "org-uuid-...",
"name": "Zimbabwe Republic Police",
"shortName": "ZRP",
"type": "LAW_ENFORCEMENT",
"isActive": true,
"deactivationReason": null,
"createdAt": "2026-03-06T10:30:00Z",
"updatedAt": "2026-03-06T10:30:00Z"
}
type is one of LAW_ENFORCEMENT, COUNCIL, PARKING, REGISTRY, SYSTEM_PROVIDER. shortName is what fines snapshot as issuedBy.organisation.
Endpoints¶
| Endpoint | Auth | Description |
|---|---|---|
POST /system-parameters/organizations |
ORGANIZATION_MANAGE |
Create from { name, shortName, type }. 201 with the organisation, active. |
GET /system-parameters/organizations |
ORGANIZATION_READ or ORGANIZATION_MANAGE |
A page of organisations (see Pagination) |
GET /system-parameters/organizations/{id} |
ORGANIZATION_READ or ORGANIZATION_MANAGE |
One organisation |
PATCH /system-parameters/organizations/{id}/edit |
ORGANIZATION_MANAGE |
Replace name, shortName and type |
PATCH /system-parameters/organizations/{id}/deactivate |
ORGANIZATION_MANAGE |
Deactivate, with the optional { "reason": "..." } body described under fine categories |
PATCH /system-parameters/organizations/{id}/activate |
ORGANIZATION_MANAGE |
Activate and clear deactivationReason |
Error Cases:
| Status | Condition |
|---|---|
400 |
Invalid fields, or a reason longer than 500 characters |
404 |
Organisation not found |
409 |
Name or short name already used by another organisation |
Other reference data¶
system-parameters-service serves currencies, payment methods and notification channels the same way: POST to create, GET for a page or /{id}, and PATCH /{id}/activate and /{id}/deactivate (with the same optional reason body). Every record returns isActive, deactivationReason, createdAt and updatedAt.
| Resource | Path | Read / write permission | Fields |
|---|---|---|---|
| Currencies | /system-parameters/currencies |
CURRENCY_READ or CURRENCY_MANAGE / CURRENCY_MANAGE |
name, code (e.g. USD, at most 5 characters), rate (units per US dollar). Also PATCH /{id}/edit, which replaces all three. |
| Payment methods | /system-parameters/payment-methods |
PAYMENT_METHOD_READ / PAYMENT_METHOD_MANAGE |
code, name, description |
| Notification channels | /system-parameters/notification-channels |
NOTIFICATION_CHANNEL_READ / NOTIFICATION_CHANNEL_MANAGE |
code, name, description |
A payment method's or notification channel's code is a stable UPPER_SNAKE identifier (BANK_TRANSFER, SMS) that clients match on instead of the display name. On create, code is optional (it must match ^[A-Z][A-Z0-9_]*$, at most 50 characters) and is derived from the name when left out (Bank transfer → BANK_TRANSFER). A code or name that is already used returns 409.
6.12 Admin — Audit Logs¶
Served by audit-service under /audit-logs. Read-only — audit logs are never modified. Every endpoint needs AUDIT_LOG_READ. A super admin sees every entry; anyone else sees only entries whose organizationId is their own organisation.
Audit entry object¶
{
"id": "log-uuid-...",
"actor": {
"id": "officer-uuid-...",
"fullName": "Const. J. Dube",
"role": "ZRP_OFFICER"
},
"organizationId": "org-uuid-...",
"action": "CREATE_FINE",
"entityType": "Fine",
"entityId": "fine-uuid-...",
"beforeState": null,
"afterState": {
"id": "fine-uuid-...",
"referenceNumber": "TG-2026-00123",
"amount": 20000,
"status": "PENDING"
},
"createdAt": "2026-03-06T10:30:00Z"
}
| Field | Description |
|---|---|
actor.fullName |
The actor's name snapshotted when the event happened (actorName on the event — see Kafka Design.md). null for entries recorded before names were captured. |
actor.role |
The actor's role at the time, or null if the event carried an unknown role |
organizationId |
The organisation the action belongs to; null for actors outside any organisation |
beforeState, afterState |
JSON values (objects, not strings) — the record as its service returned it before and after the change. beforeState is null on create. |
GET /audit-logs¶
Returns a page of audit entries, newest first by default.
Query Parameters: all optional, combined with AND.
| Parameter | Type | Description |
|---|---|---|
actorId |
UUID | Filter by the user who performed the action |
organizationId |
UUID | Filter by organisation (organisationId is accepted too) |
action |
string | Exact action, e.g. CREATE_FINE |
entityType |
string | Exact entity type, e.g. Fine |
entityId |
UUID | Filter by the affected record's id |
from |
timestamp | Created at or after (ISO 8601 instant) |
to |
timestamp | Created at or before (ISO 8601 instant) |
q |
string | Part of the actor's name, the action, entity type, entity id, or the before/after JSON, ignoring case |
page |
integer | Zero-based, default 0 |
size |
integer | 1–200, default 20 |
sort |
string | One of createdAt, action, entityType, entityId, actorId, actorName, actorRole, organizationId, with ,asc or ,desc. Default createdAt,desc. |
Response: 200 OK — a page of audit entry objects (see Pagination).
Error Cases:
| Status | Condition |
|---|---|
400 |
Negative page, size outside 1–200, an unknown sort field, or a malformed id or timestamp |
GET /audit-logs/{id}¶
Returns one audit entry object.
Error Cases:
| Status | Condition |
|---|---|
404 |
Entry not found, or outside the caller's organisation |
Older lookups¶
These return a plain list (not a page) in an older flat shape, newest first, and stay for existing clients: GET /audit-logs/actor/{actorId}, GET /audit-logs/organization/{organizationId} (403 for another organisation unless super admin) and GET /audit-logs/entity/{entityType}/{entityId}.
TrafficGrid API Specification | 2026 | Internal Document