Skip to content

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
  2. Authentication
  3. Response Envelope
  4. Pagination
  5. Error Handling
  6. Modules

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 httpOnly cookie 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:

  1. POST /citizens/me/vehicles creates the link as PENDING_VERIFICATION and 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.
  2. vehicle-service polls CVR for every pending link (every 30 seconds by default, CVR_POLL_INTERVAL). When CVR decides, the link becomes VERIFIED (with its verification time) or REJECTED, and vehicle-service publishes VERIFY_CITIZEN_VEHICLE or REJECT_CITIZEN_VEHICLE on trafficgrid.vehicles (see Kafka Design.md). A CVR error leaves the link pending for the next poll.
  3. 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:

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 0 is REJECTED
  • 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 CITIZEN reads only fines on their own verified vehicles (see 6.4 Citizen Vehicles). payments-service asks vehicle-service for the caller's links and keeps only VERIFIED ones, 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 with 503. 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 becomes CLEARED.
  • REJECTED: the fine becomes PENDING, 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, default PT1S) it finds ECOCASH payments that have been PENDING for longer than trafficgrid.ecocash.confirmation-delay (ECOCASH_CONFIRMATION_DELAY, default PT3S).
  • It settles each one exactly as the mock callback does, with a MOCK-TXN-… transaction id and the message Approved or Declined. A success publishes SUCCEED_PAYMENT, which settles the fines to PAID.
  • A payer phone ending in 0000 is declined while the payment's fines have fewer than trafficgrid.ecocash.mock-failures-before-success (ECOCASH_MOCK_FAILURES_BEFORE_SUCCESS, default 2) earlier FAILED payments, 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) is true. It defaults to false; the dev Docker stack turns it on. trafficgrid.ecocash.mock-outcome (ECOCASH_MOCK_OUTCOME) is AUTO (the 0000 rule above, the default), SUCCESS or FAILED for every payment. At startup it logs Mock EcoCash enabled: confirms ECOCASH payments after PT3S, outcome AUTO, and each settlement logs Mock 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