G
GovRate APIDOCS
134
…
Developer hub · Sandbox against your running API

GovRate REST documentation

Explore system probes and Sprint endpoints. Check live / offline services below, then use the sandbox against your running API.

134 endpoints134 live sandbox20 sections
01/system
GET

Health Check

Liveness probe. Returns OK if the API process is running.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "status": "ok",
  "service": "govrate-api",
  "timestamp": "2026-10-28T17:00:00.000Z"
}

Snippet

const res = await fetch("/api/health");
const data = await res.json();
console.log(data);
SandboxLIVE
02/system
GET

Runtime Config

Port/appUrl from `.env` plus integration liveliness (live / test / mock / attention) — no secret values.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "port": "<PORT from .env>",
  "appUrl": "https://….ngrok-free.dev",
  "paymentProvider": "paystack",
  "paymentMode": "test",
  "paymentConfigured": true,
  "emailProvider": "console|http|resend",
  "emailConfigured": true,
  "integrations": {
    "summary": {
      "live": 4,
      "offline": 6
    },
    "items": [
      {
        "id": "payments",
        "label": "Cashless payments",
        "service": "Paystack",
        "state": "live",
        "detail": "Online (test mode) — MoMo demo number 0551234987"
      },
      {
        "id": "email",
        "label": "Email notify",
        "service": "Resend",
        "state": "live",
        "detail": "Online — Resend API key set"
      }
    ]
  }
}

Snippet

const res = await fetch("/api/config");
const data = await res.json();
console.log(data.integrations?.summary, data.integrations?.items);
SandboxLIVE
03/system
GET

Object Storage Info

Active object-storage driver (`mock` | `neon`), bucket name, and ADR 003 key layouts for property + profile media. No secrets. Use with GAP-019 Neon Object Storage.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "driver": "neon",
  "bucket": "govrate",
  "layout": {
    "property": "regions/{regionCode}/assemblies/{assemblyCode}/properties/{pin}/{assetId}.{ext}",
    "userProfile": "regions/{regionCode}/assemblies/{assemblyCode}/users/{userId}/profile/{assetId}.{ext}"
  },
  "note": "Neon Object Storage (S3 path-style)."
}

Snippet

const res = await fetch("/api/v1/storage/info");
const data = await res.json();
console.log(data.driver, data.bucket, data.layout);
SandboxLIVE
04/system
PUT

Mock Storage Upload

Mock driver only: PUT raw bytes when `STORAGE_DRIVER=mock`. Query `key` + `contentType`. Returns 404 when driver is `neon` (use the Neon signed `upload.uploadUrl` from presign instead).

Request schema

application/json
Query
keystringquery
required

Object key from a prior presign response

contentTypestringquery
required

Must match presign contentType

Example path / query

{
  "key": "regions/GAR/assemblies/GSMA-01/properties/GSMA-2026-001/demo.jpg",
  "contentType": "image/jpeg"
}

Example response (200)

{
  "ok": true,
  "key": "regions/GAR/assemblies/GSMA-01/properties/GSMA-2026-001/demo.jpg",
  "byteSize": 204800
}

Snippet

const key = encodeURIComponent("regions/GAR/assemblies/GSMA-01/properties/GSMA-2026-001/demo.jpg");
const res = await fetch(`/api/v1/storage/mock-upload?key=${key}&contentType=${encodeURIComponent("image/jpeg")}`, {
  method: "PUT",
  headers: { "Content-Type": "image/jpeg" },
  body: /* File / Blob */ undefined,
});
const data = await res.json();
SandboxLIVE
05/system
GET

Readiness Check

Readiness probe. Confirms PostgreSQL connectivity (Neon).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "status": "ready",
  "database": "up"
}

Snippet

const res = await fetch("/api/ready");
const data = await res.json();
console.log(data);
SandboxLIVE
06/tenancy
GET

Tenancy Host Info

Portal host map (ADR 006 / GAP-031): marketing, app, hq, region, assembly under TENANCY_BASE_DOMAIN.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "baseDomain": "govrate.local",
  "kinds": [
    "marketing",
    "app",
    "hq",
    "region",
    "assembly"
  ]
}

Snippet

const res = await fetch("/api/v1/tenancy/info");
const data = await res.json();
SandboxLIVE
07/tenancy
GET

Resolve Host Tenant

Uses Host / X-Forwarded-Host to resolve marketing | app | hq | region | assembly and portal URLs.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "tenant": {
    "kind": "assembly",
    "assemblyCode": "GSMA-01",
    "regionCode": "GAR"
  },
  "urls": {
    "app": "http://app.govrate.local:4000",
    "hq": "http://hq.govrate.local:4000"
  }
}

Snippet

const res = await fetch("/api/v1/tenancy/resolve");
const data = await res.json();
SandboxLIVE
08/auth
POST

Staff Login

Username/email + password. Issues HTTP-only JWT cookie. Elevated roles receive TOTP MFA (authenticator app). Taxpayers use SMS OTP endpoints. Rate-limited 10/15m (GAP-026).

Request schema

application/json
Body
identifierstringbody
required

Username or email

passwordstringbody
required

Staff password

Example JSON body

{
  "identifier": "gsma.localit",
  "password": "ChangeMe1!"
}

Example response (200)

{
  "user": {
    "username": "gsma.localit",
    "role": "local_it_admin",
    "permissions": [
      "users:provision"
    ]
  }
}

Snippet

const res = await fetch("/api/v1/auth/login", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ identifier: "gsma.localit", password: "ChangeMe1!" }),
});
const data = await res.json();
SandboxLIVE
09/auth
GET

Auth Rate Limit Policies

Fixed-window limits for login (10/15m), Quick PIN (5/15m), OTP request (5/15m), OTP verify (10/15m). Exceeded attempts return 429 + Retry-After (GAP-026).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "driver": "memory",
  "policies": {
    "login": {
      "name": "login",
      "limit": 10,
      "windowMs": 900000
    },
    "quickPin": {
      "name": "quick_pin",
      "limit": 5,
      "windowMs": 900000
    }
  }
}

Snippet

const res = await fetch("/api/v1/auth/rate-limit/info");
const data = await res.json();
SandboxLIVE
10/auth
POST

Field Officer Quick PIN Login

Staff ID + 4-digit Quick PIN for field_officer only. Same JWT cookie as password login. Seed demo: gsma.officer / 2468. Rate-limited 5/15m (GAP-026).

Request schema

application/json
Body
identifierstringbody
required

Username or email

pinstringbody
required

Exactly 4 digits

Example JSON body

{
  "identifier": "gsma.officer",
  "pin": "2468"
}

Example response (200)

{
  "method": "quick_pin",
  "user": {
    "username": "gsma.officer",
    "role": "field_officer",
    "quickPinEnabled": true
  }
}

Snippet

const res = await fetch("/api/v1/auth/quick-pin/login", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ identifier: "gsma.officer", pin: "2468" }),
});
SandboxLIVE
11/auth
POST

Set Field Officer Quick PIN

Authenticated field_officer. Confirms with account password, then stores bcrypt of 4-digit PIN.

Request schema

application/json
Body
pinstringbody
required

New 4-digit PIN

passwordstringbody
required

Current account password

Example JSON body

{
  "pin": "2468",
  "password": "ChangeMe1!"
}

Example response (200)

{
  "quickPinEnabled": true,
  "message": "Quick PIN saved"
}

Snippet

const res = await fetch("/api/v1/auth/quick-pin/set", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ pin: "2468", password: "ChangeMe1!" }),
});
SandboxLIVE
12/auth
POST

Confirm TOTP Enrollment

First-time setup only. Use when login returns mfaEnrollmentRequired (not for seeded national.it — that account is already enrolled). Confirm with the authenticator code for the secret from that login response.

Request schema

application/json
Body
challengeIdstringbody
required

Challenge id from enrollment login response

codestringbody
required

Current authenticator code

Example JSON body

{
  "challengeId": "<uuid>",
  "code": "123456"
}

Example response (200)

{
  "enrolled": true,
  "user": {
    "username": "national.it",
    "totpEnabled": true
  }
}

Snippet

const res = await fetch("/api/v1/auth/mfa/totp/confirm", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ challengeId: "<uuid>", code: "123456" }),
});
const data = await res.json();
SandboxLIVE
13/auth
POST

Verify TOTP MFA

Use this after login returns mfaRequired (enrolled users, including seeded national.it). Paste challengeId from login + current 6-digit authenticator code. Secret for national.it: JBSWY3DPEHPK3PXP.

Request schema

application/json
Body
challengeIdstringbody
required

Challenge id from MFA login response

codestringbody
required

6-digit code from authenticator app

Example JSON body

{
  "challengeId": "<uuid>",
  "code": "123456"
}

Example response (200)

{
  "user": {
    "username": "national.it",
    "role": "national_it_admin",
    "totpEnabled": true
  }
}

Snippet

const res = await fetch("/api/v1/auth/mfa/verify", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ challengeId: "<uuid>", code: "123456" }),
});
const data = await res.json();
SandboxLIVE
14/auth
GET

Taxpayer OTP / SMS Info

SMS provider (console|http). Set SMS_PROVIDER=http + SMS_HTTP_URL for a real gateway (GAP-001).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "channel": "sms",
  "provider": "console",
  "configured": true
}

Snippet

const res = await fetch("/api/v1/auth/otp/info");
const info = await res.json();
SandboxLIVE
15/auth
POST

Request Taxpayer OTP

Starts SMS OTP login via phoneNumber or ghanaCardNumber. SMS_PROVIDER=console → code in API/Vercel logs and response debugCode (browser console). Live intek/http never returns debugCode. 503 if delivery fails for a known taxpayer. Rate-limited 5/15m (GAP-026).

Request schema

application/json
Body
phoneNumberstringbody
optional

Registered taxpayer phone (or use ghanaCardNumber)

ghanaCardNumberstringbody
optional

Registered Ghana Card PIN (alternative to phone)

Example JSON body

{
  "phoneNumber": "+233240000099"
}

Example response (200)

{
  "challengeId": "<uuid>",
  "expiresInSeconds": 600,
  "message": "If the identity is registered, a code was sent",
  "provider": "console",
  "debugCode": "123456"
}

Snippet

const res = await fetch("/api/v1/auth/otp/request", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ phoneNumber: "+233240000099" }),
});
const data = await res.json();
if (data.debugCode) console.info("[GovRate OTP]", data.debugCode);
SandboxLIVE
16/auth
POST

Verify Taxpayer OTP

Exchanges a valid OTP challenge for an HTTP-only session cookie.

Request schema

application/json
Body
challengeIdstringbody
required

Challenge id from otp/request

codestringbody
required

6-digit code

Example JSON body

{
  "challengeId": "<uuid>",
  "code": "123456"
}

Example response (200)

{
  "user": {
    "role": "taxpayer",
    "phoneNumber": "+233240000099"
  }
}

Snippet

const res = await fetch("/api/v1/auth/otp/verify", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ challengeId: "<uuid>", code: "123456" }),
});
SandboxLIVE
17/auth
GET

Current Session

Returns the authenticated user from the govrate_session cookie. homeAssembly resolves code + name (e.g. GSMA-01 · Ga South Municipal Assembly) when homeAssemblyId is set.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "user": {
    "username": "gsma.localit",
    "role": "local_it_admin",
    "assemblyId": "<uuid>",
    "homeAssemblyId": "<uuid>",
    "homeAssembly": {
      "id": "<uuid>",
      "code": "GSMA-01",
      "name": "Ga South Municipal Assembly"
    },
    "regionId": "<uuid-or-null>",
    "quickPinEnabled": false
  }
}

Snippet

const res = await fetch("/api/v1/auth/me", { credentials: "include" });
const data = await res.json();
// data.user.homeAssembly?.code + name for display
SandboxLIVE
18/auth
POST

Logout

Clears the govrate_session cookie. Writes auth.logout audit when a session is present (GAP-020).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (204)

null

Snippet

await fetch("/api/v1/auth/logout", {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
19/auth
GET

Check Sub-Permission

Example gated route requiring users:provision. Returns 200 for local IT, 403 for field officers.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "ok": true,
  "permission": "users:provision"
}

Snippet

const res = await fetch("/api/v1/auth/permissions/check", {
  credentials: "include",
});
const data = await res.json();
SandboxLIVE
20/kyc
GET

KYC Webhook URL

Public URL to paste into VerifyGH Keys → webhook. Localhost will not receive Vercel callbacks — set APP_URL to an ngrok/deployed HTTPS origin first.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "webhookUrl": "http://localhost:4000/api/v1/kyc/webhooks/verifygh",
  "provider": "verifygh"
}

Snippet

const res = await fetch("/api/v1/kyc/webhook-info");
const data = await res.json();
SandboxLIVE
21/kyc
POST

Verify Ghana Card (KYC)

Taxpayer only. No video field — selfieImage (JPEG data-URI) + livenessPassed/score. VerifyGH → 202 PENDING until webhook or GET /kyc/me poll. 409 if already PASSED (card locked) or still PENDING. Mock provider returns sync PASSED/FAILED. Card ending in 0 matches in sandbox.

Request schema

application/json
Body
ghanaCardNumberstringbody
required

Ghana Card PIN GHA-#########-#

firstNamestringbody
required

First name on card

lastNamestringbody
required

Last name on card

dateOfIssuestringbody
required

YYYY-MM-DD

dateOfExpirystringbody
required

YYYY-MM-DD

selfieImagestringbody
optional

Selfie as data:image/jpeg;base64,… (replace with a real face photo for biometric match)

livenessPassedbooleanbody
optional

Liveness check passed (false = still photo / fail)

livenessMovementScorenumberbody
optional

Motion liveness score 0–1

Example JSON body

{
  "ghanaCardNumber": "GHA-123456789-0",
  "firstName": "Kwame",
  "lastName": "Mensah",
  "dateOfIssue": "2021-06-01",
  "dateOfExpiry": "2031-06-01",
  "selfieImage": "data:image/jpeg;base64,…",
  "livenessPassed": true,
  "livenessMovementScore": 0.8
}

Example response (202)

{
  "status": "PENDING",
  "verification": {
    "referenceId": "ref_…",
    "provider": "verifygh"
  },
  "webhookUrl": "https://…/api/v1/kyc/webhooks/verifygh"
}

Snippet

const res = await fetch("/api/v1/kyc/ghana-card/verify", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    ghanaCardNumber: "GHA-123456789-0",
    firstName: "Kwame",
    lastName: "Mensah",
    dateOfIssue: "2021-06-01",
    dateOfExpiry: "2031-06-01",
    selfieImage: "data:image/jpeg;base64,...",
    livenessPassed: true,
    livenessMovementScore: 0.8,
  }),
});
const data = await res.json();
SandboxLIVE
22/kyc
GET

Poll KYC Verification

Taxpayer-only. Polls VerifyGH by referenceId and syncs local status when the webhook did not arrive.

Request schema

application/json
Path
referenceIdstringpath
required

referenceId from verify response

Example path / query

{
  "referenceId": "ref_1793232067052_488f9041"
}

Example response (200)

{
  "kycVerified": true,
  "source": "verifygh_poll",
  "verification": {
    "status": "PASSED"
  }
}

Snippet

const res = await fetch("/api/v1/kyc/verifications/ref_…", { credentials: "include" });
const data = await res.json();
SandboxLIVE
23/kyc
GET

My KYC Status

Taxpayer-only. kycVerified matches verify lock (ghanaCardNumber or any PASSED). latestVerification prefers PASSED so a later FAILED cannot un-verify. mostRecentVerification is the absolute newest when it differs. PENDING auto-polls VerifyGH once.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "ghanaCardNumber": "GHA-123456789-0",
  "kycVerified": true,
  "syncSource": "verifygh_poll",
  "latestVerification": {
    "status": "PASSED",
    "referenceId": "ref_…"
  },
  "passedVerification": {
    "status": "PASSED",
    "referenceId": "ref_…"
  },
  "mostRecentVerification": null
}

Snippet

const res = await fetch("/api/v1/kyc/me", { credentials: "include" });
const data = await res.json();
SandboxLIVE
24/properties
GET

List Properties

Staff: assembly-scoped list. Taxpayer: own properties only. Optional zoneCode filter.

Request schema

application/json
Query
zoneCodestringquery
optional

Optional zone filter

Example path / query

{
  "zoneCode": "ZONE-A"
}

Example response (200)

{
  "count": 1,
  "properties": [
    {
      "pin": "GSMA-2026-001",
      "zoneCode": "ZONE-A"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/properties?zoneCode=ZONE-A", { credentials: "include" });
const data = await res.json();
SandboxLIVE
25/properties
POST

Create Property

Staff/field roles. Provide lat+lng and/or ghanaPostGps. Omit pin to auto-generate {ASSEMBLY}-{ZONE}-{YYYY}-{NNN} (e.g. GSMA-ZONEA-2026-001). Address-only creates geocode via GhanaPOST into PostGIS Point(4326).

Request schema

application/json
Body
pinstringbody
optional

Optional UPN; omit for server auto-generate

categorystringbody
required

residential | commercial | industrial | mixed_use

structuralStatestringbody
required

completed | uncompleted | abandoned

zoneCodestringbody
required

Zone code

rateableValuenumberbody
required

Rateable value GHS

ghanaPostGpsstringbody
optional

Digital address — geocoded to lat/lng when coords omitted

latnumberbody
optional

Latitude (optional if ghanaPostGps set)

lngnumberbody
optional

Longitude (optional if ghanaPostGps set)

landmarkOrStreetstringbody
optional

Optional landmark / street note

fillGhanaPostGpsbooleanbody
optional

If true and only lat/lng given, reverse-fill digital address

assemblyIdstringbody
optional

Required for national/regional staff creating into an MMDA

imageUrlstringbody
optional

Optional property photo URL

ownerIdstringbody
optional

Optional taxpayer user UUID

isExemptbooleanbody
optional

Exempt from rates when true

Example JSON body

{
  "pin": "GSMA-2026-002",
  "category": "residential",
  "structuralState": "completed",
  "zoneCode": "ZONE-A",
  "rateableValue": 250000,
  "ghanaPostGps": "GJ-098-8372"
}

Example response (201)

{
  "property": {
    "pin": "GSMA-2026-002",
    "location": {
      "lat": 5.556,
      "lng": -0.3331
    }
  }
}

Snippet

const res = await fetch("/api/v1/properties", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    pin: "GSMA-2026-002",
    category: "residential",
    structuralState: "completed",
    zoneCode: "ZONE-A",
    rateableValue: 250000,
    ghanaPostGps: "GJ-098-8372",
  }),
});
SandboxLIVE
26/properties
GET

Claim Preview (Taxpayer)

Limited fields for UPN claim. claimable when unclaimed + KYC PASSED. May return pending_review while awaiting staff approve.

Request schema

application/json
Path
pinstringpath
required

Property UPN

Example path / query

{
  "pin": "GSMA-2026-001"
}

Example response (200)

{
  "status": "unclaimed",
  "claimable": true,
  "property": {
    "pin": "GSMA-2026-001",
    "claimed": false
  }
}

Snippet

const res = await fetch("/api/v1/properties/GSMA-2026-001/claim-preview", {
  credentials: "include",
});
SandboxLIVE
27/properties
POST

Submit Claim (Taxpayer)

Creates pending_review claim after KYC PASSED. Does NOT set ownerId — staff must approve. Pay is blocked until linked.

Request schema

application/json
Path
pinstringpath
required

Property UPN

Example path / query

{
  "pin": "GSMA-2026-001"
}

Example response (201)

{
  "submitted": true,
  "claimed": false,
  "claimStatus": "pending_review",
  "claimId": "<uuid>",
  "property": {
    "pin": "GSMA-2026-001",
    "ownerId": null
  }
}

Snippet

const res = await fetch("/api/v1/properties/GSMA-2026-001/claim", {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
28/properties
GET

My Claims (Taxpayer)

List own property claims for the dashboard tracker (GAP-036).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "count": 1,
  "claims": [
    {
      "status": "pending_review",
      "pin": "GSMA-2026-001"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/claims/me", { credentials: "include" });
SandboxLIVE
29/properties
GET

List Claims (Staff)

Assembly/region-scoped claim queue. Filter status=pending_review.

Request schema

application/json
Query
statusstringquery
optional

pending_review | approved | rejected

Example path / query

{
  "status": "pending_review"
}

Example response (200)

{
  "count": 1,
  "claims": [
    {
      "status": "pending_review"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/claims?status=pending_review", { credentials: "include" });
SandboxLIVE
30/properties
POST

Approve Claim (Staff)

Links ownerId to taxpayer and marks claim approved. First approve may set home MMDA (ADR 004).

Request schema

application/json
Path
idstringpath
required

Claim UUID

Example JSON body

{
  "note": "Docs OK"
}

Example response (200)

{
  "approved": true,
  "claim": {
    "status": "approved"
  }
}

Snippet

const res = await fetch("/api/v1/claims/<id>/approve", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ note: "Docs OK" }),
});
SandboxLIVE
31/properties
POST

Reject Claim (Staff)

Marks claim rejected; property stays unlinked.

Request schema

application/json
Path
idstringpath
required

Claim UUID

Example JSON body

{
  "note": "Mismatch"
}

Example response (200)

{
  "rejected": true,
  "claim": {
    "status": "rejected"
  }
}

Snippet

const res = await fetch("/api/v1/claims/<id>/reject", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ note: "Mismatch" }),
});
SandboxLIVE
32/properties
GET

Get Property by PIN

Returns property + lat/lng + embedded tax liability (GAP-008) + media gallery (`media.items`, up to 30). Taxpayer may only read owned PINs. Auto UPN may look like GSMA-ZONEA-2026-001 (GAP-021); seed demo remains GSMA-2026-001.

Request schema

application/json
Path
pinstringpath
required

Unique property identification number.

Example path / query

{
  "pin": "GSMA-2026-001"
}

Example response (200)

{
  "property": {
    "pin": "GSMA-2026-001",
    "zoneCode": "ZONE-A",
    "category": "residential",
    "rateableValue": 350000,
    "imageUrl": "https://…signed…",
    "updatedAt": "2026-10-30T08:00:00.000Z",
    "location": {
      "lat": 5.556,
      "lng": -0.3331
    }
  },
  "media": {
    "count": 2,
    "items": [
      {
        "id": "uuid",
        "kind": "facade",
        "isPrimary": true,
        "downloadUrl": "https://…"
      }
    ]
  },
  "liability": {
    "outstandingTotal": 630,
    "billCount": 1,
    "openBillCount": 1,
    "bills": [
      {
        "fiscalYear": 2026,
        "amountDue": 630,
        "amountPaid": 0,
        "outstanding": 630,
        "status": "unpaid"
      }
    ]
  }
}

Snippet

const res = await fetch("/api/v1/properties/GSMA-2026-001", { credentials: "include" });
const data = await res.json();
SandboxLIVE
33/properties
PATCH

Update Property

Staff write roles. Partial update — send only fields to change. Optional lat+lng relocates the PostGIS point.

Request schema

application/json
Path
pinstringpath
required

PIN to update

Body
rateableValuenumberbody
optional

New rateable value GHS

zoneCodestringbody
optional

Zone code

categorystringbody
optional

residential | commercial | industrial | mixed_use

structuralStatestringbody
optional

completed | uncompleted | abandoned

landmarkOrStreetstringbody
optional

Landmark / street note

latnumberbody
optional

New latitude (with lng)

lngnumberbody
optional

New longitude (with lat)

ghanaPostGpsstringbody
optional

New digital address (may re-geocode)

imageUrlstringbody
optional

Optional photo URL

ownerIdstringbody
optional

Taxpayer user UUID (or null to clear)

isExemptbooleanbody
optional

Rate exemption flag

Example JSON body

{
  "rateableValue": 360000,
  "zoneCode": "ZONE-A",
  "landmarkOrStreet": "Near Weija Dam"
}

Example response (200)

{
  "property": {
    "pin": "GSMA-2026-001",
    "rateableValue": 360000,
    "location": {
      "lat": 5.556,
      "lng": -0.3331
    }
  }
}

Snippet

const res = await fetch("/api/v1/properties/GSMA-2026-001", {
  method: "PATCH",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ rateableValue: 360000, landmarkOrStreet: "Near Weija Dam" }),
});
SandboxLIVE
34/properties
DELETE

Soft-Delete Property

Staff write roles. Sets is_active=false (PIN remains reserved).

Request schema

application/json
Path
pinstringpath
required

PIN to soft-delete

Example path / query

{
  "pin": "GSMA-2026-001"
}

Example response (204)

null

Snippet

const res = await fetch("/api/v1/properties/GSMA-2026-001", {
  method: "DELETE",
  credentials: "include",
});
SandboxLIVE
35/properties
GET

Properties in Bounding Box

PostGIS ST_Intersects envelope query. Staff only. Optional assemblyId for national/regional.

Request schema

application/json
Query
minLngnumberquery
required

West

minLatnumberquery
required

South

maxLngnumberquery
required

East

maxLatnumberquery
required

North

Example path / query

{
  "minLng": -0.35,
  "minLat": 5.54,
  "maxLng": -0.32,
  "maxLat": 5.57
}

Example response (200)

{
  "count": 1,
  "properties": [
    {
      "pin": "GSMA-2026-001"
    }
  ]
}

Snippet

const qs = new URLSearchParams({
  minLng: "-0.35", minLat: "5.54", maxLng: "-0.32", maxLat: "5.57",
});
const res = await fetch(`/api/v1/properties/bbox?${qs}`, { credentials: "include" });
SandboxLIVE
36/geo
GET

GhanaPOST Geocode Info

Provider status. Official GhanaPOST has no public API; default is SperixLabs community geocoder.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "provider": "sperixlabs",
  "baseUrl": "https://ghanapostgps.sperixlabs.org"
}

Snippet

const res = await fetch("/api/v1/geo/ghanapost/info");
const data = await res.json();
SandboxLIVE
37/geo
POST

Geocode GhanaPOST Address

Digital address → WGS84 center lat/lng (auth required).

Request schema

application/json
Body
addressstringbody
required

GhanaPOST GPS digital address

Example JSON body

{
  "address": "GJ-098-8372"
}

Example response (200)

{
  "provider": "sperixlabs",
  "location": {
    "address": "GJ-098-8372",
    "lat": 5.5559886,
    "lng": -0.3331177
  }
}

Snippet

const res = await fetch("/api/v1/geo/ghanapost/geocode", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ address: "GJ-098-8372" }),
});
SandboxLIVE
38/geo
POST

Reverse Geocode to GhanaPOST

lat/lng → nearest digital address (auth required).

Request schema

application/json
Body
latnumberbody
required

Latitude

lngnumberbody
required

Longitude

Example JSON body

{
  "lat": 5.556,
  "lng": -0.3331
}

Example response (200)

{
  "provider": "sperixlabs",
  "location": {
    "address": "GJ-098-8372",
    "lat": 5.556,
    "lng": -0.3331
  }
}

Snippet

const res = await fetch("/api/v1/geo/ghanapost/reverse", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ lat: 5.556, lng: -0.3331 }),
});
SandboxLIVE
39/valuation
GET

List Valuation Rules

Assembly rate imposts by zone, category, and effective year. Filter status (GAP-029).

Request schema

application/json
Query
effectiveYearnumberquery
optional

Fiscal / effective year

zoneCodestringquery
optional

Optional zone filter

statusstringquery
optional

draft | pending_approval | approved | rejected

Example path / query

{
  "effectiveYear": 2026,
  "zoneCode": "ZONE-A",
  "status": "approved"
}

Example response (200)

{
  "count": 1,
  "rules": [
    {
      "zoneCode": "ZONE-A",
      "category": "residential",
      "rateImpost": 0.0018,
      "status": "approved"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/valuation-rules?effectiveYear=2026&status=approved", { credentials: "include" });
SandboxLIVE
40/valuation
GET

Valuation Rule Approval Info

Statuses and maker/approver roles for rate impost Maker-Checker (GAP-029).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "statuses": [
    "draft",
    "pending_approval",
    "approved",
    "rejected"
  ],
  "approverRoles": [
    "municipal_executive",
    "local_it_admin"
  ]
}

Snippet

const res = await fetch("/api/v1/valuation-rules/info");
const data = await res.json();
SandboxLIVE
41/valuation
POST

Create Valuation Rule

Staff/IT Maker. Defaults to pending_approval (or status: draft). Unique on assembly + zone + category + year (GAP-029).

Request schema

application/json
Body
assemblyIdstringbody
optional

Optional for national/regional; local staff use session assembly

zoneCodestringbody
required

Zone code

categorystringbody
required

Property category

rateImpostnumberbody
required

Multiplier (e.g. 0.0018)

effectiveYearnumberbody
required

Effective year

Example JSON body

{
  "zoneCode": "ZONE-C",
  "category": "residential",
  "rateImpost": 0.002,
  "effectiveYear": 2026,
  "status": "pending_approval"
}

Example response (201)

{
  "rule": {
    "rateImpost": 0.002,
    "status": "pending_approval"
  }
}

Snippet

const res = await fetch("/api/v1/valuation-rules", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ zoneCode: "ZONE-C", category: "residential", rateImpost: 0.002, effectiveYear: 2026 }),
});
SandboxLIVE
42/valuation
POST

Submit Valuation Rule

draft or rejected → pending_approval (GAP-029).

Request schema

application/json
Path
idstringpath
required

Rule UUID

Example path / query

{
  "id": "<rule-uuid>"
}

Example response (200)

{
  "rule": {
    "status": "pending_approval"
  }
}

Snippet

const res = await fetch("/api/v1/valuation-rules/<id>/submit", {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
43/valuation
POST

Approve Valuation Rule

Executive/IT Checker. Cannot approve own Maker submission. Enables calculate/billing (GAP-029).

Request schema

application/json
Path
idstringpath
required

Rule UUID

Example path / query

{
  "id": "<rule-uuid>"
}

Example response (200)

{
  "rule": {
    "status": "approved"
  }
}

Snippet

const res = await fetch("/api/v1/valuation-rules/<id>/approve", {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
44/valuation
POST

Reject Valuation Rule

Executive/IT Checker. Optional reason. No self-reject (GAP-029).

Request schema

application/json
Path
idstringpath
required

Rule UUID

Body
reasonstringbody
optional

Rejection reason

Example JSON body

{
  "reason": "Rate too high for zone"
}

Example response (200)

{
  "rule": {
    "status": "rejected",
    "rejectionReason": "Rate too high for zone"
  }
}

Snippet

const res = await fetch("/api/v1/valuation-rules/<id>/reject", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ reason: "Rate too high for zone" }),
});
SandboxLIVE
45/valuation
PATCH

Update Valuation Rule

Staff/IT. Patch rateImpost and/or zoneCode. Approved/rejected rules return to pending_approval (GAP-029).

Request schema

application/json
Path
idstringpath
required

Rule UUID from list

Body
rateImpostnumberbody
optional

New multiplier

zoneCodestringbody
optional

Optional new zone code

Example JSON body

{
  "rateImpost": 0.0019
}

Example response (200)

{
  "rule": {
    "id": "<uuid>",
    "rateImpost": 0.0019
  }
}

Snippet

const res = await fetch("/api/v1/valuation-rules/<id>", {
  method: "PATCH", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ rateImpost: 0.0019 }),
});
SandboxLIVE
46/valuation
POST

Compute Tax Liability

Amount Due = Rateable Value × Rate Impost. Pass pin OR assemblyId+zoneCode+category+rateableValue.

Request schema

application/json
Body
pinstringbody
optional

Property PIN (loads RV/zone/category). Prefer this OR the fields below.

assemblyIdstringbody
optional

Assembly UUID (required if no pin)

zoneCodestringbody
optional

Zone code (required if no pin)

categorystringbody
optional

residential | commercial | industrial | mixed_use

rateableValuenumberbody
optional

Assessed RV in GHS (required if no pin)

effectiveYearnumberbody
optional

Year for rule lookup

Example JSON body

{
  "pin": "GSMA-2026-001",
  "effectiveYear": 2026
}

Example response (200)

{
  "pin": "GSMA-2026-001",
  "rateableValue": 350000,
  "rateImpost": 0.0018,
  "amountDue": 630
}

Snippet

const res = await fetch("/api/v1/valuations/calculate", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ pin: "GSMA-2026-001", effectiveYear: 2026 }),
});
SandboxLIVE
47/valuation
POST

Generate Bills (Batch)

Queue mass bill creation for active non-exempt properties (RV × impost). Poll GET /billing/batches/:id.

Request schema

application/json
Body
assemblyIdstringbody
optional

Optional for national/regional; local staff use session assembly

fiscalYearnumberbody
required

Fiscal year

dueDatestringbody
required

YYYY-MM-DD

zoneCodestringbody
optional

Optional zone filter

Example JSON body

{
  "fiscalYear": 2026,
  "dueDate": "2026-12-31",
  "zoneCode": "ZONE-A"
}

Example response (202)

{
  "batchId": "<uuid>",
  "status": "queued",
  "poll": "/api/v1/billing/batches/<uuid>"
}

Snippet

const res = await fetch("/api/v1/billing/batches", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ fiscalYear: 2026, dueDate: "2026-12-31", zoneCode: "ZONE-A" }),
});
SandboxLIVE
48/valuation
GET

Poll Billing Batch Status

Returns created/updated/skipped/failed counts and per-PIN results.

Request schema

application/json
Path
idstringpath
required

batchId from POST /billing/batches

Example path / query

{
  "id": "<batch-uuid>"
}

Example response (200)

{
  "status": "completed",
  "createdCount": 1,
  "updatedCount": 0,
  "results": [
    {
      "pin": "GSMA-2026-001",
      "ok": true,
      "amountDue": 630
    }
  ]
}

Snippet

const res = await fetch("/api/v1/billing/batches/<batchId>", { credentials: "include" });
SandboxLIVE
49/valuation
GET

List Bills

Filter by pin and/or fiscalYear.

Request schema

application/json
Query
pinstringquery
optional

Property PIN

fiscalYearnumberquery
optional

Fiscal year

Example path / query

{
  "pin": "GSMA-2026-001",
  "fiscalYear": 2026
}

Example response (200)

{
  "count": 1,
  "bills": [
    {
      "pin": "GSMA-2026-001",
      "amountDue": 630,
      "status": "unpaid"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/bills?pin=GSMA-2026-001&fiscalYear=2026", { credentials: "include" });
SandboxLIVE
50/payments
GET

Payments Provider Info

mock (default) or paystack (GAP-018). Supports single billId or multi billIds (GAP-035). Test MoMo 0551234987 on sk_test_. Phone-only payers use synthetic *@payers.govrate.app (not @*.local).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "provider": "mock",
  "configured": true,
  "webhookPath": "/api/v1/payments/webhooks/mock",
  "paystackWebhookPath": "/api/v1/payments/webhooks/paystack"
}

Snippet

const res = await fetch("/api/v1/payments/info");
SandboxLIVE
51/payments
POST

Initiate Cashless Payment

Taxpayer (own bill(s)) or staff. billId or billIds (exactly one). Multi-bill MoMo/Card/mock: one charge = sum of outstanding; Bank multi not supported. Mock → webhook; Paystack → authorizationUrl / pay_offline. Test MoMo: 0551234987. Phone-only → payer-*@payers.govrate.app.

Request schema

application/json
Body
billIdstringbody
optional

Single bill UUID (mutually exclusive with billIds)

billIdsstringbody
optional

JSON array of bill UUIDs for pay-all / multi-select (mutually exclusive with billId)

amountnumberbody
optional

GHS partial — single billId only

payerPhonestringbody
optional

MoMo wallet (Paystack test: 0551234987 only)

channelstringbody
optional

MoMo | Card | Bank

Example JSON body

{
  "billId": "<bill-uuid>",
  "amount": 630,
  "payerPhone": "0551234987",
  "channel": "MoMo"
}

Example response (201)

{
  "payment": {
    "transactionReference": "GOVRATE-TX-20261029-ABC",
    "verificationStatus": "pending",
    "amountPaid": 630,
    "paymentChannel": "MoMo",
    "batchId": null
  },
  "payments": [
    {
      "transactionReference": "GOVRATE-TX-20261029-ABC",
      "amountPaid": 630,
      "verificationStatus": "pending"
    }
  ],
  "batchId": null,
  "totalAmount": 630,
  "pins": [
    "GSMA-ZONEA-2026-001"
  ]
}

Snippet

const res = await fetch("/api/v1/payments/initialize", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ billId: "<uuid>", amount: 630, channel: "MoMo", payerPhone: "0551234987" }),
});
SandboxLIVE
52/payments
POST

Pay multiple bills (one charge)

Same route with billIds[] (GAP-035). One MoMo/Card/mock charge = sum of outstanding; N payment rows share batchId. Lead transactionReference is the gateway ref — webhook applies the whole batch. Bank multi not allowed. Phone-only → payer-*@payers.govrate.app.

Request schema

application/json
Body
billIdsstringbody
required

JSON array of unpaid bill UUIDs (1..50)

payerPhonestringbody
optional

MoMo wallet (Paystack test: 0551234987 only)

channelstringbody
optional

MoMo | Card (not Bank)

Example JSON body

{
  "billIds": [
    "<bill-uuid-1>",
    "<bill-uuid-2>"
  ],
  "payerPhone": "0551234987",
  "channel": "MoMo"
}

Example response (201)

{
  "payment": {
    "transactionReference": "GOVRATE-TX-BATCH-LEAD",
    "verificationStatus": "pending",
    "amountPaid": 100,
    "batchId": "<batch-uuid>"
  },
  "payments": [
    {
      "billId": "<bill-uuid-1>",
      "amountPaid": 100,
      "transactionReference": "GOVRATE-TX-BATCH-LEAD"
    },
    {
      "billId": "<bill-uuid-2>",
      "amountPaid": 40,
      "transactionReference": "GOVRATE-TX-BATCH-LEAD:<bill-uuid-2>"
    }
  ],
  "batchId": "<batch-uuid>",
  "totalAmount": 140,
  "pins": [
    "GSMA-ZONEA-2026-001",
    "GSMA-ZONEA-2025-002"
  ]
}

Snippet

const res = await fetch("/api/v1/payments/initialize", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    billIds: ["<uuid-1>", "<uuid-2>"],
    channel: "MoMo",
    payerPhone: "0551234987",
  }),
});
SandboxLIVE
53/payments
POST

Mock Payment Webhook

HMAC SHA256 over raw body (header X-GovRate-Signature). status=success credits the bill ledger. For multi-bill, POST the lead transactionReference — all payments sharing batchId are verified (omit amountPaid override on batches).

Request schema

application/json
Body
transactionReferencestringbody
required

From initialize response

statusstringbody
required

success | failed

amountPaidnumberbody
optional

Optional override amount

Example JSON body

{
  "transactionReference": "GOVRATE-TX-…",
  "status": "success",
  "amountPaid": 630
}

Example response (200)

{
  "ok": true,
  "verificationStatus": "verified",
  "billStatus": "fully_paid"
}

Snippet

// Sign raw JSON with PAYMENT_WEBHOOK_SECRET (HMAC-SHA256)
await fetch("/api/v1/payments/webhooks/mock", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-GovRate-Signature": "sha256=…" },
  body: JSON.stringify({ transactionReference: "GOVRATE-TX-…", status: "success" }),
});
SandboxLIVE
54/payments
POST

Paystack Payment Webhook

HMAC SHA512 (x-paystack-signature). charge.success verifies pending MoMo/Card (GAP-018). Multi-bill: use the lead reference — applies every payment in batchId (GAP-035).

Request schema

application/json
Body
eventstringbody
required

Paystack event name

data.referencestringbody
required

Same as initialize transactionReference

Example JSON body

{
  "event": "charge.success",
  "data": {
    "reference": "GOVRATE-TX-…",
    "amount": 63000,
    "status": "success"
  }
}

Example response (200)

{
  "ok": true,
  "verificationStatus": "verified"
}

Snippet

await fetch("/api/v1/payments/webhooks/paystack", {
  method: "POST",
  headers: { "Content-Type": "application/json", "x-paystack-signature": "…" },
  body: JSON.stringify({
    event: "charge.success",
    data: { reference: "GOVRATE-TX-…", amount: 63000, status: "success" },
  }),
});
SandboxLIVE
55/payments
GET

Payment History

Rich history with pin, fiscalYear, receiptAvailable. Taxpayer own / staff scoped (GAP-037).

Request schema

application/json
Query
pinstringquery
optional

Filter by UPN

verificationStatusstringquery
optional

pending | verified | rejected

Example response (200)

{
  "count": 1,
  "payments": [
    {
      "pin": "GSMA-2026-001",
      "amountPaid": 180,
      "verificationStatus": "verified",
      "receiptAvailable": true
    }
  ]
}

Snippet

const res = await fetch("/api/v1/payments/history", { credentials: "include" });
SandboxLIVE
56/payments
GET

Taxpayer Credit Wallet

Non-withdrawable overpayment credit + ledger. Applied via initialize useCredit (GAP-038).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "creditBalance": 50,
  "withdrawable": false,
  "ledger": []
}

Snippet

const res = await fetch("/api/v1/payments/credit", { credentials: "include" });
SandboxLIVE
57/payments
GET

List Payments / Reconciliation

Taxpayer: own. Staff: assembly-scoped. Filter billId / pin / verificationStatus=pending for Checker inbox.

Request schema

application/json
Query
billIdstringquery
optional

Bill UUID

pinstringquery
optional

Property PIN

verificationStatusstringquery
optional

pending | verified | rejected

Example path / query

{
  "pin": "GSMA-2026-001",
  "verificationStatus": "pending"
}

Example response (200)

{
  "count": 1,
  "payments": [
    {
      "verificationStatus": "pending",
      "paymentChannel": "Bank"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/payments?verificationStatus=pending", { credentials: "include" });
SandboxLIVE
58/payments
GET

Get Payment

Single payment row (taxpayer own / staff scoped).

Request schema

application/json
Path
idstringpath
required

Payment UUID

Example path / query

{
  "id": "<payment-uuid>"
}

Example response (200)

{
  "payment": {
    "id": "<uuid>",
    "verificationStatus": "verified",
    "amountPaid": 630
  }
}

Snippet

const res = await fetch(`/api/v1/payments/${id}`, {
  credentials: "include",
});
const { payment } = await res.json();
SandboxLIVE
59/payments
POST

Verify Payment (Checker)

municipal_staff / municipal_executive. Cannot verify own Maker payment. Credits bill ledger.

Request schema

application/json
Path
idstringpath
required

Pending payment UUID

Example path / query

{
  "id": "<payment-uuid>"
}

Example response (200)

{
  "payment": {
    "verificationStatus": "verified"
  },
  "billStatus": "fully_paid"
}

Snippet

await fetch("/api/v1/payments/<id>/verify", { method: "POST", credentials: "include" });
SandboxLIVE
60/payments
POST

Reject Payment (Checker)

Reject pending payment; no bill credit. Cannot reject own Maker row.

Request schema

application/json
Path
idstringpath
required

Pending payment UUID

Body
reasonstringbody
optional

Optional reject reason

Example JSON body

{
  "reason": "Slip illegible"
}

Example response (200)

{
  "payment": {
    "verificationStatus": "rejected"
  },
  "reason": "Slip illegible"
}

Snippet

await fetch("/api/v1/payments/<id>/reject", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ reason: "Slip illegible" }),
});
SandboxLIVE
61/payments
GET

Download Payment Receipt (PDF)

Verified payments only. Returns application/pdf.

Request schema

application/json
Path
idstringpath
required

Payment UUID

Example path / query

{
  "id": "<payment-uuid>"
}

Example response (200)

{
  "contentType": "application/pdf",
  "note": "binary PDF body"
}

Snippet

const res = await fetch("/api/v1/payments/<id>/receipt.pdf", { credentials: "include" });
const blob = await res.blob();
SandboxLIVE
62/notifications
GET

Notifications Channel Info

SMS / email / Web Push providers (GAP-025). Exposes VAPID public key when configured.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "channels": {
    "sms": {
      "provider": "console",
      "configured": true
    },
    "email": {
      "provider": "console",
      "configured": true
    },
    "push": {
      "provider": "webpush",
      "configured": false,
      "vapidPublicKey": null
    }
  },
  "kinds": [
    "bill.issued",
    "payment.received",
    "news.published"
  ]
}

Snippet

const res = await fetch("/api/v1/notifications/info");
const info = await res.json();
SandboxLIVE
63/notifications
GET

List My Notifications

In-app inbox for the session user.

Request schema

application/json
Query
unreadOnlybooleanquery
optional

Only unread

Example path / query

{
  "unreadOnly": true
}

Example response (200)

{
  "count": 1,
  "unreadCount": 1,
  "notifications": [
    {
      "kind": "bill.issued",
      "title": "Property rates 2026",
      "body": "Bill for PIN GSMA-ZONEA-2026-001…"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/notifications?unreadOnly=true", {
  credentials: "include",
});
const { notifications } = await res.json();
SandboxLIVE
64/notifications
POST

Register Web Push Subscription

Store browser PushSubscription keys for VAPID delivery.

Request schema

application/json
Body
endpointstringbody
required

PushService endpoint

keys.p256dhstringbody
required

p256dh key

keys.authstringbody
required

auth key

Example JSON body

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/example",
  "keys": {
    "p256dh": "…",
    "auth": "…"
  }
}

Example response (201)

{
  "subscription": {
    "id": "<uuid>",
    "endpoint": "…"
  }
}

Snippet

const res = await fetch("/api/v1/push/subscriptions", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    endpoint: subscription.endpoint,
    keys: { p256dh: btoa(…), auth: btoa(…) },
  }),
});
SandboxLIVE
65/notifications
GET

Announcement Scopes Info

national | regional | assembly scopes (GAP-032) + topics rates|gra|assemblies (GAP-040).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "scopes": [
    "national",
    "regional",
    "assembly"
  ],
  "topics": [
    "rates",
    "gra",
    "assemblies"
  ]
}

Snippet

const res = await fetch("/api/v1/announcements/info");
SandboxLIVE
66/notifications
GET

Public Announcements Feed

No auth. Marketing site / app feed of published news (default scope=national). Optional topic filter (GAP-040).

Request schema

application/json
Query
scopestringquery
optional

national | regional | assembly

topicstringquery
optional

rates | gra | assemblies

Example path / query

{
  "scope": "national",
  "topic": "rates"
}

Example response (200)

{
  "count": 1,
  "announcements": [
    {
      "scope": "national",
      "topic": "rates",
      "title": "Nationwide notice",
      "summary": "Card teaser",
      "imageUrl": "https://cdn.example.com/news.jpg"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/announcements/public?scope=national&topic=rates");
SandboxLIVE
67/notifications
POST

Create / Publish Announcement

Scoped news (GAP-032) + card fields summary/imageUrl/topic (GAP-040). national=national IT; regional=regional/national; assembly=municipal/IT. publish:true notifies audience.

Request schema

application/json
Body
scopestringbody
optional

national | regional | assembly

titlestringbody
required

Headline

summarystringbody
optional

Card teaser (≤512)

bodystringbody
required

Full message body

imageUrlstringbody
optional

Optional HTTPS thumb URL

topicstringbody
optional

rates | gra | assemblies

publishbooleanbody
optional

Publish immediately and notify

Example JSON body

{
  "scope": "assembly",
  "title": "Rate collection drive",
  "summary": "Pay at Weija this week",
  "body": "Pay at the assembly office this week.",
  "imageUrl": "https://cdn.example.com/news/drive.jpg",
  "topic": "rates",
  "publish": true
}

Example response (201)

{
  "announcement": {
    "scope": "assembly",
    "topic": "rates",
    "title": "Rate collection drive",
    "summary": "Pay at Weija this week",
    "imageUrl": "https://cdn.example.com/news/drive.jpg",
    "publishedAt": "2026-10-30T09:00:00.000Z"
  }
}

Snippet

const res = await fetch("/api/v1/announcements", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    scope: "assembly",
    title: "Rate collection drive",
    summary: "Pay at Weija this week",
    body: "Pay at the assembly this week.",
    imageUrl: "https://cdn.example.com/news/drive.jpg",
    topic: "rates",
    publish: true,
  }),
});
SandboxLIVE
68/notifications
POST

Mark Notification Read

Mark a single inbox item as read (session owner only).

Request schema

application/json
Path
idstringpath
required

Notification UUID from list

Example path / query

{
  "id": "<notification-uuid>"
}

Example response (200)

{
  "notification": {
    "id": "<uuid>",
    "readAt": "2026-10-30T09:00:00.000Z"
  }
}

Snippet

const res = await fetch(`/api/v1/notifications/${id}/read`, {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
69/notifications
POST

Mark All Notifications Read

Mark every unread inbox item for the session user.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "ok": true,
  "markedRead": 3
}

Snippet

await fetch("/api/v1/notifications/read-all", {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
70/notifications
DELETE

Remove Web Push Subscription

Delete by PushService endpoint for the session user.

Request schema

application/json
Body
endpointstringbody
required

Same endpoint used at subscribe

Example JSON body

{
  "endpoint": "https://example/push"
}

Example response (204)

null

Snippet

await fetch("/api/v1/push/subscriptions", {
  method: "DELETE",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ endpoint: subscription.endpoint }),
});
SandboxLIVE
71/notifications
GET

List Announcements

Published news for an assembly. Staff may pass includeDrafts=true.

Request schema

application/json
Query
assemblyIdstringquery
optional

Defaults to session assembly when set

includeDraftsbooleanquery
optional

Staff/IT only

Example path / query

{
  "includeDrafts": false
}

Example response (200)

{
  "count": 1,
  "announcements": [
    {
      "title": "Rate collection drive",
      "publishedAt": "2026-10-30T09:00:00.000Z"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/announcements", { credentials: "include" });
const { announcements } = await res.json();
SandboxLIVE
72/notifications
POST

Publish Announcement

Publish a draft and notify assembly audience (SMS/email/push + inbox).

Request schema

application/json
Path
idstringpath
required

Draft announcement id

Example path / query

{
  "id": "<announcement-uuid>"
}

Example response (200)

{
  "announcement": {
    "id": "<uuid>",
    "publishedAt": "2026-10-30T09:00:00.000Z"
  }
}

Snippet

await fetch(`/api/v1/announcements/${id}/publish`, {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
73/marketing-news
GET

Marketing News Info

External RSS tabs for Figma-style marketing feed (GAP-041). Separate from staff announcements.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "tabs": [
    "rates",
    "gra",
    "assemblies",
    "headlines"
  ]
}

Snippet

const res = await fetch("/api/v1/marketing-news/info");
SandboxLIVE
74/marketing-news
GET

Public Marketing News Feed

No auth. Publisher stories with outbound url + optional imageUrl. Filter by tab.

Request schema

application/json
Query
tabstringquery
optional

rates | gra | assemblies | headlines

limitnumberquery
optional

Max stories (1–100)

Example path / query

{
  "tab": "rates",
  "limit": 20
}

Example response (200)

{
  "count": 1,
  "stories": [
    {
      "tab": "rates",
      "headline": "Assembly digitises property rates",
      "source": "MyJoyOnline",
      "url": "https://www.myjoyonline.com/…"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/marketing-news/public?tab=rates");
SandboxLIVE
75/marketing-news
POST

Cron: Ingest Marketing RSS

Bearer CRON_SECRET. Vercel GET cron daily (06:00 UTC) also hits this path. Pulls Ghana publisher feeds, classifies, upserts.

Request schema

application/json

Example response (200)

{
  "ok": true,
  "fetched": 40,
  "classified": 3,
  "inserted": 2,
  "updated": 0,
  "skipped": 37,
  "pruned": 0
}

Snippet

const res = await fetch("/api/v1/cron/marketing-news", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.CRON_SECRET}` },
});
SandboxLIVE
76/analytics
GET

Analytics Info

KPI endpoints and scope rules for dashboards.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "endpoints": [
    "GET /api/v1/analytics/summary",
    "GET /api/v1/analytics/properties/distribution"
  ]
}

Snippet

const res = await fetch("/api/v1/analytics/info");
SandboxLIVE
77/analytics
GET

Revenue & Collection Summary

Amount due/paid, collection rate, bill status counts, verified payments, open disputes. Scoped by role.

Request schema

application/json
Query
fiscalYearnumberquery
optional

Fiscal year

assemblyIdstringquery
optional

Optional for national/regional/supervisory

Example path / query

{
  "fiscalYear": 2026
}

Example response (200)

{
  "summary": {
    "fiscalYear": 2026,
    "amountDue": 630,
    "amountPaid": 630,
    "collectionRate": 1
  }
}

Snippet

const res = await fetch("/api/v1/analytics/summary?fiscalYear=2026", { credentials: "include" });
SandboxLIVE
78/analytics
GET

Property Distribution

Active properties by category and zone with rateable value sums.

Request schema

application/json
Query
assemblyIdstringquery
optional

Optional for national/regional/supervisory

Example response (200)

{
  "distribution": {
    "totalActive": 1,
    "byCategory": [
      {
        "category": "residential",
        "count": 1
      }
    ],
    "byZone": [
      {
        "zoneCode": "ZONE-A",
        "count": 1
      }
    ]
  }
}

Snippet

const res = await fetch("/api/v1/analytics/properties/distribution", { credentials: "include" });
SandboxLIVE
79/media
POST

Presign Property Media Upload

Staff/field write roles. Creates pending PropertyMedia + presigned PUT (`upload.uploadUrl` = mock path or Neon signed URL). Then PUT bytes and call complete. See System → Object Storage Info. Keys: regions/…/assemblies/…/properties/{pin}/…

Request schema

application/json
Path
pinstringpath
required

Property PIN

Body
contentTypestringbody
required

image/jpeg | image/png | image/webp | image/gif

kindstringbody
optional

facade | structure | interior | other

byteSizenumberbody
optional

Expected size (max 12MB)

Example JSON body

{
  "contentType": "image/jpeg",
  "kind": "facade",
  "byteSize": 204800
}

Example response (201)

{
  "media": {
    "id": "uuid",
    "objectKey": "regions/GAR/assemblies/GSMA-01/properties/GSMA-2026-001/….jpg",
    "kind": "facade",
    "status": "pending",
    "contentType": "image/jpeg"
  },
  "upload": {
    "uploadUrl": "https://….storage….aws.neon.tech/govrate/regions/…?X-Amz-Signature=…",
    "headers": {
      "Content-Type": "image/jpeg"
    },
    "expiresInSeconds": 900
  },
  "driver": "neon"
}

Snippet

const res = await fetch("/api/v1/properties/GSMA-2026-001/media/presign", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ contentType: "image/jpeg", kind: "facade", byteSize: 204800 }),
});
const { media, upload, driver } = await res.json();
// await fetch(upload.uploadUrl, { method: "PUT", headers: upload.headers, body: fileBlob });
SandboxLIVE
80/media
POST

Complete Property Media Upload

Marks media active after object exists in storage (mock auto-accepts if PUT skipped in tests). Returns short-lived downloadUrl; may set property.imageUrl for facade.

Request schema

application/json
Path
pinstringpath
required

Property PIN

idstringpath
required

Media UUID from presign

Example path / query

{
  "pin": "GSMA-2026-001"
}

Example response (200)

{
  "media": {
    "id": "uuid",
    "status": "active",
    "kind": "facade",
    "contentType": "image/jpeg",
    "byteSize": 204800
  },
  "downloadUrl": "https://…"
}

Snippet

const res = await fetch(`/api/v1/properties/GSMA-2026-001/media/${mediaId}/complete`, {
  method: "POST",
  credentials: "include",
});
const data = await res.json();
SandboxLIVE
81/media
POST

Replace Property Image (presign)

Update an existing gallery image in place (same media id). Up to 30 active images per property. Staff + owning taxpayer.

Request schema

application/json
Path
pinstringpath
required

Property PIN

idstringpath
required

Existing active media UUID

Body
contentTypestringbody
required

image/jpeg | image/png | image/webp | image/gif

Example JSON body

{
  "contentType": "image/jpeg"
}

Example response (200)

{
  "media": {
    "id": "uuid",
    "pendingObjectKey": "regions/…/new.jpg",
    "status": "active"
  },
  "upload": {
    "uploadUrl": "https://…",
    "headers": {
      "Content-Type": "image/jpeg"
    }
  },
  "driver": "neon"
}

Snippet

const res = await fetch(`/api/v1/properties/GSMA-2026-001/media/${mediaId}/replace/presign`, {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ contentType: "image/jpeg" }),
});
SandboxLIVE
82/media
POST

Replace Property Image (complete)

After PUT to replace upload URL — swaps object key and deletes the old blob.

Request schema

application/json
Path
pinstringpath
required

Property PIN

idstringpath
required

Media UUID

Example path / query

{
  "pin": "GSMA-2026-001"
}

Example response (200)

{
  "replaced": true,
  "media": {
    "id": "uuid",
    "status": "active"
  },
  "downloadUrl": "https://…"
}

Snippet

await fetch(`/api/v1/properties/GSMA-2026-001/media/${mediaId}/replace/complete`, {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
83/media
POST

Set Primary Property Image

Marks this active gallery image as primary (`property.imageUrl` = object key). Gallery can still hold many images.

Request schema

application/json
Path
pinstringpath
required

Property PIN

idstringpath
required

Active media UUID

Example path / query

{
  "pin": "GSMA-2026-001"
}

Example response (200)

{
  "primaryMediaId": "uuid",
  "imageObjectKey": "regions/…/….jpg",
  "downloadUrl": "https://…"
}

Snippet

await fetch(`/api/v1/properties/GSMA-2026-001/media/${mediaId}/primary`, {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
84/media
GET

List Property Media

All active gallery images (max 30) with signed GET URLs + isPrimary. Also embedded on GET /properties/:pin as media.items. Taxpayer: own properties only.

Request schema

application/json
Path
pinstringpath
required

Property PIN

Query
limitnumberquery
optional

1–30

Example path / query

{
  "pin": "GSMA-2026-001",
  "limit": 30
}

Example response (200)

{
  "count": 3,
  "maxActive": 30,
  "driver": "neon",
  "media": [
    {
      "id": "uuid",
      "kind": "facade",
      "isPrimary": true,
      "contentType": "image/jpeg",
      "downloadUrl": "https://…"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/properties/GSMA-2026-001/media", {
  credentials: "include",
});
const data = await res.json();
SandboxLIVE
85/media
DELETE

Delete Property Media

Staff or owning taxpayer. Deletes storage object + property_media row (204). Reassigns primary if needed.

Request schema

application/json
Path
pinstringpath
required

Property PIN

idstringpath
required

Media UUID

Example path / query

{
  "pin": "GSMA-2026-001"
}

Example response (204)

null

Snippet

const res = await fetch(`/api/v1/properties/GSMA-2026-001/media/${mediaId}`, {
  method: "DELETE",
  credentials: "include",
});
SandboxLIVE
86/media
POST

Presign Profile Media Upload

Taxpayer or field_officer. Requires homeAssemblyId (claim / duty MMDA). Keys under ADR 003 user profile prefix. kind: avatar | selfie | other.

Request schema

application/json
Body
contentTypestringbody
required

image/jpeg | image/png | image/webp | image/gif

kindstringbody
optional

avatar | selfie | other

byteSizenumberbody
optional

Expected size (max 8MB)

Example JSON body

{
  "contentType": "image/jpeg",
  "kind": "avatar",
  "byteSize": 102400
}

Example response (201)

{
  "media": {
    "id": "uuid",
    "objectKey": "regions/GAR/assemblies/GSMA-01/users/<userId>/profile/….jpg",
    "kind": "avatar",
    "status": "pending",
    "assemblyId": "<home-assembly-uuid>"
  },
  "upload": {
    "uploadUrl": "https://….storage….aws.neon.tech/govrate/regions/…?X-Amz-Signature=…",
    "headers": {
      "Content-Type": "image/jpeg"
    },
    "expiresInSeconds": 900
  },
  "driver": "neon"
}

Snippet

const res = await fetch("/api/v1/users/me/media/presign", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ contentType: "image/jpeg", kind: "avatar", byteSize: 102400 }),
});
const { media, upload, driver } = await res.json();
// await fetch(upload.uploadUrl, { method: "PUT", headers: upload.headers, body: fileBlob });
SandboxLIVE
87/media
POST

Complete Profile Media Upload

Activates profile media after PUT. Completing a new avatar marks prior avatars as replaced.

Request schema

application/json
Path
idstringpath
required

Media UUID from presign

Example response (200)

{
  "media": {
    "id": "uuid",
    "status": "active",
    "kind": "avatar",
    "contentType": "image/jpeg"
  },
  "downloadUrl": "https://…"
}

Snippet

const res = await fetch(`/api/v1/users/me/media/${mediaId}/complete`, {
  method: "POST",
  credentials: "include",
});
const data = await res.json();
SandboxLIVE
88/media
POST

Replace Profile Pic (presign)

Update an existing profile image in place (same media id). Taxpayer / field_officer. Prefer this or a new avatar upload (old avatars → replaced).

Request schema

application/json
Path
idstringpath
required

Active media UUID

Body
contentTypestringbody
required

image/jpeg | image/png | image/webp | image/gif

Example JSON body

{
  "contentType": "image/jpeg",
  "kind": "avatar"
}

Example response (200)

{
  "media": {
    "id": "uuid",
    "pendingObjectKey": "regions/…/profile/….jpg",
    "status": "active"
  },
  "upload": {
    "uploadUrl": "https://…",
    "headers": {
      "Content-Type": "image/jpeg"
    }
  },
  "driver": "neon"
}

Snippet

await fetch(`/api/v1/users/me/media/${mediaId}/replace/presign`, {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ contentType: "image/jpeg", kind: "avatar" }),
});
SandboxLIVE
89/media
POST

Replace Profile Pic (complete)

After PUT — swaps object key, deletes old blob, keeps the same media id.

Request schema

application/json
Path
idstringpath
required

Media UUID

Example response (200)

{
  "replaced": true,
  "media": {
    "id": "uuid",
    "status": "active"
  },
  "downloadUrl": "https://…"
}

Snippet

await fetch(`/api/v1/users/me/media/${mediaId}/replace/complete`, {
  method: "POST",
  credentials: "include",
});
SandboxLIVE
90/media
GET

List My Profile Media

Active profile media + signed GET URLs for the session user.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "count": 1,
  "homeAssemblyId": "<uuid>",
  "driver": "mock",
  "media": [
    {
      "id": "uuid",
      "kind": "avatar",
      "contentType": "image/jpeg",
      "downloadUrl": "https://…"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/users/me/media", { credentials: "include" });
const data = await res.json();
SandboxLIVE
91/media
GET

List User Profile Media (Staff)

Staff hierarchy: same home assembly (local/municipal), region (regional IT), or national/supervisory. Field/taxpayer cannot call this path.

Request schema

application/json
Path
userIdstringpath
required

Target user UUID

Example response (200)

{
  "count": 1,
  "userId": "<uuid>",
  "homeAssemblyId": "<uuid>",
  "media": [
    {
      "id": "uuid",
      "kind": "avatar",
      "downloadUrl": "https://…"
    }
  ]
}

Snippet

const res = await fetch(`/api/v1/users/${userId}/media`, { credentials: "include" });
const data = await res.json();
SandboxLIVE
92/media
DELETE

Delete Profile Media

Taxpayer or field_officer. Deletes own profile object + user_media row (204).

Request schema

application/json
Path
idstringpath
required

Media UUID

Example response (204)

null

Snippet

const res = await fetch(`/api/v1/users/me/media/${mediaId}`, {
  method: "DELETE",
  credentials: "include",
});
SandboxLIVE
93/gis
GET

Navigate to Property (Maps)

Field Officer deep-links from cached PostGIS coords (no GhanaPOST re-query). Google Maps / Apple Maps / OSM. Optional officer GPS as origin.

Request schema

application/json
Path
pinstringpath
required

Destination property PIN

Query
origin_latnumberquery
optional

Field officer latitude (optional)

origin_lngnumberquery
optional

Field officer longitude (optional)

Example path / query

{
  "pin": "GSMA-2026-001",
  "origin_lat": 5.5512,
  "origin_lng": -0.2011
}

Example response (200)

{
  "pin": "GSMA-2026-001",
  "destination": {
    "lat": 5.556,
    "lng": -0.3331
  },
  "googleMaps": "https://www.google.com/maps/dir/?api=1&destination=5.556,-0.3331",
  "appleMaps": "https://maps.apple.com/?daddr=5.556,-0.3331&dirflg=d"
}

Snippet

const qs = new URLSearchParams({ origin_lat: "5.5512", origin_lng: "-0.2011" });
const res = await fetch(`/api/v1/properties/GSMA-2026-001/navigate?${qs}`, {
  credentials: "include",
});
const { googleMaps, appleMaps } = await res.json();
// Field PWA: window.open(/iPhone|iPad|Mac/.test(navigator.userAgent) ? appleMaps : googleMaps)
SandboxLIVE
94/gis
GET

Office Locations Info

GAP-022 — MMDA / region / national head-office GPS kinds.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "kinds": [
    "assembly",
    "region",
    "national"
  ],
  "note": "Official offices for directions (GAP-022)."
}

Snippet

const res = await fetch("/api/v1/offices/info");
const info = await res.json();
SandboxLIVE
95/gis
GET

List My Offices

Duty/home assembly office + its region + national HQ (GAP-022).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "count": 3,
  "offices": [
    {
      "kind": "assembly",
      "code": "GSMA-01",
      "name": "Ga South Municipal Assembly",
      "ghanaPostGps": "GJ-098-8372",
      "landmark": "Ga South Municipal Assembly Head Office (Weija)",
      "location": {
        "lat": 5.556,
        "lng": -0.3331
      }
    }
  ]
}

Snippet

const res = await fetch("/api/v1/offices", { credentials: "include" });
const { offices } = await res.json();
SandboxLIVE
96/gis
GET

Get Assembly Office

MMDA head-office GhanaPOST / coords.

Request schema

application/json
Path
codestringpath
required

Assembly code

Example path / query

{
  "code": "GSMA-01"
}

Example response (200)

{
  "office": {
    "kind": "assembly",
    "code": "GSMA-01",
    "ghanaPostGps": "GJ-098-8372",
    "location": {
      "lat": 5.556,
      "lng": -0.3331
    }
  }
}

Snippet

const res = await fetch("/api/v1/assemblies/GSMA-01/office", {
  credentials: "include",
});
const { office } = await res.json();
SandboxLIVE
97/gis
PATCH

Update Assembly Office

Local IT / municipal write (own MMDA), regional IT (region), national IT (any). Same geocode rules as properties.

Request schema

application/json
Path
codestringpath
required

Assembly code

Body
ghanaPostGpsstringbody
optional

Digital address

landmarkstringbody
optional

Landmark / building name

latnumberbody
optional

Latitude (with lng)

lngnumberbody
optional

Longitude (with lat)

Example JSON body

{
  "ghanaPostGps": "GJ-098-8372",
  "landmark": "Ga South Municipal Assembly Head Office",
  "lat": 5.556,
  "lng": -0.3331
}

Example response (200)

{
  "office": {
    "kind": "assembly",
    "code": "GSMA-01",
    "ghanaPostGps": "GJ-098-8372",
    "location": {
      "lat": 5.556,
      "lng": -0.3331
    }
  }
}

Snippet

const res = await fetch("/api/v1/assemblies/GSMA-01/office", {
  method: "PATCH",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    landmark: "Ga South Municipal Assembly Head Office",
    lat: 5.556,
    lng: -0.3331,
  }),
});
const { office } = await res.json();
SandboxLIVE
98/gis
GET

Navigate to Assembly Office

Google / Apple / OSM deep-links to MMDA head office.

Request schema

application/json
Path
codestringpath
required

Assembly code

Query
origin_latnumberquery
optional

Optional origin latitude

origin_lngnumberquery
optional

Optional origin longitude

Example path / query

{
  "code": "GSMA-01",
  "origin_lat": 5.5512,
  "origin_lng": -0.2011
}

Example response (200)

{
  "kind": "assembly",
  "code": "GSMA-01",
  "googleMaps": "https://www.google.com/maps/dir/?api=1&destination=5.556,-0.3331",
  "appleMaps": "https://maps.apple.com/?daddr=5.556,-0.3331&dirflg=d"
}

Snippet

const qs = new URLSearchParams({ origin_lat: "5.5512", origin_lng: "-0.2011" });
const res = await fetch(`/api/v1/assemblies/GSMA-01/office/navigate?${qs}`, {
  credentials: "include",
});
const { googleMaps, appleMaps } = await res.json();
SandboxLIVE
99/gis
GET

Get Region Office

Regional coordinating council / office coords.

Request schema

application/json
Path
codestringpath
required

Region code

Example path / query

{
  "code": "GAR"
}

Example response (200)

{
  "office": {
    "kind": "region",
    "code": "GAR",
    "ghanaPostGps": "GA-039-5028",
    "location": {
      "lat": 5.6037,
      "lng": -0.1969
    }
  }
}

Snippet

const res = await fetch("/api/v1/regions/GAR/office", {
  credentials: "include",
});
const { office } = await res.json();
SandboxLIVE
100/gis
PATCH

Update Region Office

Regional IT (own region) or national IT. Same geocode rules as properties.

Request schema

application/json
Path
codestringpath
required

Region code

Body
landmarkstringbody
optional

Landmark / building name

latnumberbody
optional

Latitude (with lng)

lngnumberbody
optional

Longitude (with lat)

Example JSON body

{
  "landmark": "Greater Accra Regional Coordinating Council",
  "lat": 5.6037,
  "lng": -0.1969
}

Example response (200)

{
  "office": {
    "kind": "region",
    "code": "GAR",
    "location": {
      "lat": 5.6037,
      "lng": -0.1969
    }
  }
}

Snippet

const res = await fetch("/api/v1/regions/GAR/office", {
  method: "PATCH",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    landmark: "Greater Accra Regional Coordinating Council",
    lat: 5.6037,
    lng: -0.1969,
  }),
});
const { office } = await res.json();
SandboxLIVE
101/gis
GET

Navigate to Region Office

Maps deep-links to regional office.

Request schema

application/json
Path
codestringpath
required

Region code

Query
origin_latnumberquery
optional

Optional origin latitude

origin_lngnumberquery
optional

Optional origin longitude

Example path / query

{
  "code": "GAR",
  "origin_lat": 5.5512,
  "origin_lng": -0.2011
}

Example response (200)

{
  "kind": "region",
  "code": "GAR",
  "googleMaps": "https://www.google.com/maps/dir/?api=1&destination=5.6037,-0.1969"
}

Snippet

const res = await fetch("/api/v1/regions/GAR/office/navigate?origin_lat=5.5512&origin_lng=-0.2011", {
  credentials: "include",
});
const { googleMaps } = await res.json();
SandboxLIVE
102/gis
GET

Get National Office

National HQ from system_config (GAP-022).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "office": {
    "kind": "national",
    "code": "NATIONAL",
    "ghanaPostGps": "GA-144-2024",
    "location": {
      "lat": 5.56,
      "lng": -0.2057
    }
  }
}

Snippet

const res = await fetch("/api/v1/offices/national", {
  credentials: "include",
});
const { office } = await res.json();
SandboxLIVE
103/gis
PATCH

Update National Office

National IT only.

Request schema

application/json
Body
landmarkstringbody
optional

Landmark

latnumberbody
optional

Latitude

lngnumberbody
optional

Longitude

Example JSON body

{
  "landmark": "Ministry of Local Government (demo HQ)",
  "lat": 5.56,
  "lng": -0.2057
}

Example response (200)

{
  "office": {
    "kind": "national",
    "landmark": "Ministry of Local Government (demo HQ)",
    "location": {
      "lat": 5.56,
      "lng": -0.2057
    }
  }
}

Snippet

const res = await fetch("/api/v1/offices/national", {
  method: "PATCH",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    landmark: "Ministry of Local Government (demo HQ)",
    lat: 5.56,
    lng: -0.2057,
  }),
});
const { office } = await res.json();
SandboxLIVE
104/gis
GET

Navigate to National Office

Maps deep-links to national HQ.

Request schema

application/json
Query
origin_latnumberquery
optional

Optional origin latitude

origin_lngnumberquery
optional

Optional origin longitude

Example path / query

{
  "origin_lat": 5.5512,
  "origin_lng": -0.2011
}

Example response (200)

{
  "kind": "national",
  "googleMaps": "https://www.google.com/maps/dir/?api=1&destination=5.56,-0.2057"
}

Snippet

const res = await fetch("/api/v1/offices/national/navigate?origin_lat=5.5512&origin_lng=-0.2011", {
  credentials: "include",
});
const { googleMaps } = await res.json();
SandboxLIVE
105/sync
GET

Offline Sync Queue Info

Driver (memory|redis/BullMQ), mutation types, optimistic conflicts (GAP-012), and media blobs (GAP-013).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "driver": "memory",
  "supportedMutationTypes": [
    "property.upsert",
    "property.patch",
    "gis.point",
    "property.media"
  ],
  "conflictResolution": {
    "mode": "optimistic",
    "fields": [
      "baseUpdatedAt",
      "force"
    ],
    "note": "Send property.updatedAt as baseUpdatedAt on patch/gis; omit for LWW; force after conflict UI"
  },
  "media": {
    "mutationType": "property.media",
    "maxDecodedBytes": 4194304,
    "contentTypes": [
      "image/jpeg",
      "image/png",
      "image/webp",
      "image/gif"
    ]
  }
}

Snippet

const res = await fetch("/api/v1/sync/info");
const data = await res.json();
SandboxLIVE
106/sync
POST

Submit Offline Sync Batch

Field roles. IndexedDB mutations (upsert/patch/gis.point/property.media). On patch/gis send payload.baseUpdatedAt = property.updatedAt (GAP-012). property.media embeds dataBase64 (GAP-013). Idempotent on clientBatchId.

Request schema

application/json
Body
clientBatchIdstringbody
required

Client-generated batch id (idempotency key per user)

assemblyIdstringbody
optional

Optional for national/regional staff

mutationsobjectbody
required

Array of { clientMutationId, type, payload }. Media: dataBase64 + contentType; optional clientAssetId (GAP-013).

Example JSON body

{
  "clientBatchId": "pwa-batch-001",
  "mutations": [
    {
      "clientMutationId": "m1",
      "type": "property.upsert",
      "payload": {
        "category": "residential",
        "structuralState": "completed",
        "zoneCode": "ZONE-A",
        "rateableValue": 180000,
        "ghanaPostGps": "GJ-098-8372"
      }
    },
    {
      "clientMutationId": "m2",
      "type": "property.patch",
      "payload": {
        "pin": "GSMA-OFF-001",
        "rateableValue": 185000,
        "baseUpdatedAt": "2026-10-30T08:00:00.000Z"
      }
    },
    {
      "clientMutationId": "m3",
      "type": "gis.point",
      "payload": {
        "pin": "GSMA-OFF-001",
        "lat": 5.557,
        "lng": -0.334,
        "accuracyMeters": 5,
        "baseUpdatedAt": "2026-10-30T08:00:00.000Z"
      }
    },
    {
      "clientMutationId": "m4",
      "type": "property.media",
      "payload": {
        "pin": "GSMA-OFF-001",
        "kind": "facade",
        "contentType": "image/png",
        "dataBase64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
        "clientAssetId": "pwa-photo-001"
      }
    }
  ]
}

Example response (202)

{
  "batchId": "<uuid>",
  "status": "queued",
  "poll": "/api/v1/sync/batches/<uuid>"
}

Snippet

const res = await fetch("/api/v1/sync/batch", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    clientBatchId: "pwa-batch-001",
    mutations: [{
      clientMutationId: "m1",
      type: "property.upsert",
      payload: {
        pin: "GSMA-OFF-001",
        category: "residential",
        structuralState: "completed",
        zoneCode: "ZONE-A",
        rateableValue: 180000,
        ghanaPostGps: "GJ-098-8372",
      },
    }],
  }),
});
SandboxLIVE
107/sync
GET

Poll Sync Batch Status

Returns queued|processing|completed|partial|failed plus per-mutation results.

Request schema

application/json
Path
idstringpath
required

batchId from POST /sync/batch

Example path / query

{
  "id": "<batch-uuid>"
}

Example response (200)

{
  "status": "completed",
  "successCount": 1,
  "failureCount": 0
}

Snippet

const res = await fetch("/api/v1/sync/batches/<batchId>", { credentials: "include" });
const data = await res.json();
SandboxLIVE
108/disputes
GET

Disputes Info

State machine: open → under_review → resolved.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "statuses": [
    "open",
    "under_review",
    "resolved"
  ],
  "transitions": "open → under_review → resolved"
}

Snippet

const res = await fetch("/api/v1/disputes/info");
const data = await res.json();
SandboxLIVE
109/disputes
POST

File Dispute (Taxpayer)

Taxpayer only. Create dispute on an owned PIN. Flags unpaid/partial bills as disputed.

Request schema

application/json
Body
pinstringbody
optional

Owned property PIN (or use propertyId)

propertyIdstringbody
optional

Optional property UUID instead of pin

subjectstringbody
required

Short subject

descriptionstringbody
required

Full complaint text

Example JSON body

{
  "pin": "GSMA-2026-001",
  "subject": "Wrong rateable value",
  "description": "RV should be lower for incomplete structure"
}

Example response (201)

{
  "dispute": {
    "id": "<uuid>",
    "status": "open",
    "pin": "GSMA-2026-001"
  }
}

Snippet

const res = await fetch("/api/v1/disputes", {
  method: "POST", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    pin: "GSMA-2026-001",
    subject: "Wrong rateable value",
    description: "RV should be lower for incomplete structure",
  }),
});
SandboxLIVE
110/disputes
GET

List Disputes

Taxpayer: own tickets. Staff: assembly inbox (filter status/pin).

Request schema

application/json
Query
statusstringquery
optional

open | under_review | resolved

pinstringquery
optional

Filter by PIN

Example path / query

{
  "status": "open",
  "pin": "GSMA-2026-001"
}

Example response (200)

{
  "count": 1,
  "disputes": [
    {
      "id": "<uuid>",
      "status": "open",
      "pin": "GSMA-2026-001"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/disputes?status=open", { credentials: "include" });
SandboxLIVE
111/disputes
GET

Get Dispute

Taxpayer (own) or staff (assembly/region scoped).

Request schema

application/json
Path
idstringpath
required

Dispute UUID

Example path / query

{
  "id": "<dispute-uuid>"
}

Example response (200)

{
  "dispute": {
    "id": "<uuid>",
    "status": "under_review",
    "pin": "GSMA-2026-001"
  }
}

Snippet

const res = await fetch("/api/v1/disputes/<id>", { credentials: "include" });
SandboxLIVE
112/disputes
PATCH

Advance Dispute Status (Staff)

Staff only. open → under_review → resolved. Invalid skips return 409. Resolving restores bill status.

Request schema

application/json
Path
idstringpath
required

Dispute UUID

Body
statusstringbody
required

Next status in the state machine

Example JSON body

{
  "status": "under_review"
}

Example response (200)

{
  "dispute": {
    "id": "<uuid>",
    "status": "under_review"
  }
}

Snippet

const res = await fetch("/api/v1/disputes/<id>", {
  method: "PATCH", credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ status: "under_review" }),
});
SandboxLIVE
113/audit
GET

List Audit Logs

Hierarchical trail (GAP-020). National/supervisory: optional filters. Regional: own region. Local IT / municipal staff/exec: own assembly. Field officer / taxpayer: self only. Auth events use eventKind like auth.login.success (never secrets).

Request schema

application/json
Query
eventKindstringquery
optional

e.g. mutation | auth.login.success | auth.logout

actionstringquery
optional

LOGIN | LOGOUT | MFA | OTP | FAIL | INSERT | UPDATE | …

assemblyIdstringquery
optional

Filter (national/regional only within scope)

regionIdstringquery
optional

Filter (national/supervisory)

userIdstringquery
optional

Filter actor (not for self-only roles beyond self)

limitnumberquery
optional

1–200

Example path / query

{
  "eventKind": "auth.login.success",
  "limit": 50
}

Example response (200)

{
  "count": 1,
  "scope": "assembly",
  "logs": [
    {
      "id": "<uuid>",
      "userId": "<uuid>",
      "assemblyId": "<uuid>",
      "regionId": "<uuid>",
      "tableName": "auth",
      "action": "LOGIN",
      "eventKind": "auth.login.success",
      "detail": "password",
      "createdAt": "2026-10-30T08:00:00.000Z"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/audit-logs?eventKind=auth.login.success&limit=50", {
  credentials: "include",
});
const data = await res.json();
SandboxLIVE
114/tenants
GET

Tenant Admin Info

Region/assembly types and write roles (GAP-028). Status changes stay on Kill Switch.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "assemblyTypes": [
    "metropolitan",
    "municipal",
    "district"
  ],
  "regionWriteRoles": [
    "national_it_admin"
  ],
  "assemblyWriteRoles": [
    "national_it_admin",
    "regional_it_admin"
  ]
}

Snippet

const res = await fetch("/api/v1/tenants/info");
const data = await res.json();
SandboxLIVE
115/tenants
GET

List Regions

Scoped: national/supervisory see all; others see their region.

Request schema

application/json
Query
qstringquery
optional

Search code/name

Example response (200)

{
  "regions": [
    {
      "code": "GAR",
      "name": "Greater Accra Region",
      "assemblyCount": 1
    }
  ]
}

Snippet

const res = await fetch("/api/v1/regions", { credentials: "include" });
const data = await res.json();
SandboxLIVE
116/tenants
POST

Create Region

national_it_admin only.

Request schema

application/json
Body
codestringbody
required

Unique region code

namestringbody
required

Display name

Example JSON body

{
  "code": "ASH",
  "name": "Ashanti Region"
}

Example response (201)

{
  "region": {
    "code": "ASH",
    "name": "Ashanti Region",
    "assemblyCount": 0
  }
}

Snippet

const res = await fetch("/api/v1/regions", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ code: "ASH", name: "Ashanti Region" }),
});
SandboxLIVE
117/tenants
GET

Get Region

By code or UUID; scoped.

Request schema

application/json
Path
codeOrIdstringpath
required

Region code or UUID

Example path / query

{
  "codeOrId": "GAR"
}

Example response (200)

{
  "region": {
    "code": "GAR",
    "name": "Greater Accra Region"
  }
}

Snippet

const res = await fetch("/api/v1/regions/GAR", { credentials: "include" });
const data = await res.json();
SandboxLIVE
118/tenants
PATCH

Patch Region

national_it_admin; update code and/or name.

Request schema

application/json
Path
codeOrIdstringpath
required

Region code or UUID

Body
namestringbody
optional

New display name

Example JSON body

{
  "name": "Greater Accra Region"
}

Example response (200)

{
  "region": {
    "code": "GAR",
    "name": "Greater Accra Region"
  }
}

Snippet

const res = await fetch("/api/v1/regions/GAR", {
  method: "PATCH",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Greater Accra Region" }),
});
SandboxLIVE
119/tenants
GET

List Assemblies

Scoped MMDA list. Filter with regionId or regionCode (national).

Request schema

application/json
Query
regionCodestringquery
optional

Filter by region code

Example path / query

{
  "regionCode": "GAR"
}

Example response (200)

{
  "assemblies": [
    {
      "code": "GSMA-01",
      "name": "Ga South Municipal Assembly",
      "assemblyType": "municipal",
      "status": "active"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/assemblies?regionCode=GAR", { credentials: "include" });
const data = await res.json();
SandboxLIVE
120/tenants
POST

Create Assembly

national_it_admin or regional_it_admin (own region). Provide regionId or regionCode.

Request schema

application/json
Body
regionCodestringbody
optional

Parent region code (or use regionId)

codestringbody
required

Unique assembly / MMDA code

namestringbody
required

Display name

assemblyTypestringbody
required

metropolitan | municipal | district

Example JSON body

{
  "regionCode": "GAR",
  "code": "DEMO-01",
  "name": "Demo Municipal Assembly",
  "assemblyType": "municipal",
  "currency": "GHS"
}

Example response (201)

{
  "assembly": {
    "code": "DEMO-01",
    "assemblyType": "municipal",
    "status": "active"
  }
}

Snippet

const res = await fetch("/api/v1/assemblies", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    regionCode: "GAR",
    code: "DEMO-01",
    name: "Demo Municipal Assembly",
    assemblyType: "municipal",
  }),
});
SandboxLIVE
121/tenants
GET

Get Assembly

By code or UUID; scoped.

Request schema

application/json
Path
codeOrIdstringpath
required

Assembly code or UUID

Example path / query

{
  "codeOrId": "GSMA-01"
}

Example response (200)

{
  "assembly": {
    "code": "GSMA-01",
    "name": "Ga South Municipal Assembly",
    "status": "active"
  }
}

Snippet

const res = await fetch("/api/v1/assemblies/GSMA-01", { credentials: "include" });
const data = await res.json();
SandboxLIVE
122/tenants
PATCH

Patch Assembly

Update name/type/currency/code. regionId move = national only. Status via Kill Switch.

Request schema

application/json
Path
codeOrIdstringpath
required

Assembly code or UUID

Body
namestringbody
optional

New display name

Example JSON body

{
  "name": "Ga South Municipal Assembly"
}

Example response (200)

{
  "assembly": {
    "code": "GSMA-01",
    "name": "Ga South Municipal Assembly"
  }
}

Snippet

const res = await fetch("/api/v1/assemblies/GSMA-01", {
  method: "PATCH",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Ga South Municipal Assembly" }),
});
SandboxLIVE
123/taxpayers
GET

Taxpayer Onboard Info

Field-assisted onboarding roles (GAP-030). Taxpayers use OTP login — no password.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "onboardRoles": [
    "field_officer",
    "municipal_staff",
    "local_it_admin"
  ]
}

Snippet

const res = await fetch("/api/v1/taxpayers/info");
const data = await res.json();
SandboxLIVE
124/taxpayers
GET

Lookup Taxpayer

Find existing taxpayer by phoneNumber or ghanaCardNumber before onboard.

Request schema

application/json
Query
phoneNumberstringquery
optional

Taxpayer phone

Example path / query

{
  "phoneNumber": "+233240000099"
}

Example response (200)

{
  "found": true,
  "taxpayer": {
    "role": "taxpayer",
    "phoneNumber": "+233240000099"
  }
}

Snippet

const res = await fetch("/api/v1/taxpayers/lookup?phoneNumber=%2B233240000099", {
  credentials: "include",
});
SandboxLIVE
125/taxpayers
POST

Onboard Taxpayer (Assisted)

Field officer / municipal staff / local IT creates OTP-only taxpayer. Home MMDA = session assembly (ADR 004 / GAP-030).

Request schema

application/json
Body
fullNamestringbody
required

Legal / display name

phoneNumberstringbody
required

Unique phone for OTP login

ghanaCardNumberstringbody
optional

Optional GHA-#########-#

Example JSON body

{
  "fullName": "Ama Mensah",
  "phoneNumber": "+233241111111",
  "ghanaCardNumber": "GHA-123456789-0"
}

Example response (201)

{
  "taxpayer": {
    "role": "taxpayer",
    "phoneNumber": "+233241111111",
    "homeAssemblyId": "<uuid>"
  },
  "message": "Taxpayer created. They can log in with phone/Ghana Card + SMS OTP."
}

Snippet

const res = await fetch("/api/v1/taxpayers/onboard", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    fullName: "Ama Mensah",
    phoneNumber: "+233241111111",
    ghanaCardNumber: "GHA-123456789-0",
  }),
});
SandboxLIVE
126/users
GET

User Admin Info

Provision hierarchy for national / regional / local IT.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "hierarchy": {
    "local_it_admin": [
      "field_officer",
      "municipal_staff",
      "municipal_executive"
    ]
  }
}

Snippet

const res = await fetch("/api/v1/users/info");
const data = await res.json();
SandboxLIVE
127/users
GET

List Users

IT admins. Scoped by hierarchy (local → assembly staff; regional → local IT; national → broad).

Request schema

application/json
Query
rolestringquery
optional

Optional role filter

isActivestringquery
optional

true | false

qstringquery
optional

Search username / name / email

Example path / query

{
  "role": "field_officer",
  "isActive": "true"
}

Example response (200)

{
  "count": 1,
  "users": [
    {
      "username": "gsma.officer",
      "role": "field_officer",
      "isActive": true
    }
  ]
}

Snippet

const res = await fetch("/api/v1/users?role=field_officer", { credentials: "include" });
const data = await res.json();
SandboxLIVE
128/users
POST

Provision User

IT hierarchy. Local IT creates field/staff; regional creates local IT; national creates regional / local / supervisory. .gov.gh email for staff/IT.

Request schema

application/json
Body
rolestringbody
required

Target role (must be creatable by caller)

fullNamestringbody
required

Display name

usernamestringbody
required

Unique login id

emailstringbody
optional

Required .gov.gh for staff/IT roles

phoneNumberstringbody
required

Unique phone

passwordstringbody
required

Min 8, letter + digit

assemblyIdstringbody
optional

Required when national/regional creates local_it_admin

regionIdstringbody
optional

Required when creating regional_it_admin

Example JSON body

{
  "role": "field_officer",
  "fullName": "Ama Mensah",
  "username": "gsma.fo.demo",
  "email": "fo.demo@gasouth.gov.gh",
  "phoneNumber": "+233241000001",
  "password": "ChangeMe1!"
}

Example response (201)

{
  "user": {
    "id": "<uuid>",
    "role": "field_officer",
    "isActive": true
  }
}

Snippet

const res = await fetch("/api/v1/users", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    role: "field_officer",
    fullName: "Ama Mensah",
    username: "gsma.fo.demo",
    email: "fo.demo@gasouth.gov.gh",
    phoneNumber: "+233241000001",
    password: "ChangeMe1!",
  }),
});
SandboxLIVE
129/users
PATCH

Update User

Profile, sub_permissions, suspend (isActive=false). Cannot deactivate self.

Request schema

application/json
Path
idstringpath
required

User UUID

Body
isActivebooleanbody
optional

Suspend / reactivate

subPermissionsobjectbody
optional

JSONB permission strings

Example JSON body

{
  "isActive": false,
  "subPermissions": [
    "payments:verify"
  ]
}

Example response (200)

{
  "user": {
    "id": "<uuid>",
    "isActive": false
  }
}

Snippet

const res = await fetch("/api/v1/users/<id>", {
  method: "PATCH",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ isActive: false }),
});
SandboxLIVE
130/users
POST

Reset User Password

IT admin sets a new password and bumps tokenVersion so outstanding session cookies are revoked immediately (GAP-027).

Request schema

application/json
Path
idstringpath
required

User UUID

Body
passwordstringbody
required

New password

Example JSON body

{
  "password": "ChangeMe1!"
}

Example response (200)

{
  "ok": true,
  "userId": "<uuid>",
  "tokenVersion": 1,
  "message": "Password updated. Existing sessions were revoked; the user must log in again."
}

Snippet

const res = await fetch("/api/v1/users/<id>/reset-password", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ password: "ChangeMe1!" }),
});
SandboxLIVE
131/killswitch
GET

Kill Switch Info

Status enum (active | read_only | offline) and admin paths.

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "statuses": [
    "active",
    "read_only",
    "offline"
  ],
  "endpoints": [
    "GET /api/v1/admin/kill-switch",
    "PATCH .../system",
    "PATCH .../assemblies/:code"
  ]
}

Snippet

const res = await fetch("/api/v1/admin/kill-switch/info");
const data = await res.json();
SandboxLIVE
132/killswitch
GET

Get Kill Switch Status

National / regional / supervisory. System status + assembly list (regional scoped to region).

Request schema

application/json

No request parameters (cookie / empty body).

Example response (200)

{
  "system": {
    "status": "active"
  },
  "assemblies": [
    {
      "code": "GSMA-01",
      "status": "active"
    }
  ]
}

Snippet

const res = await fetch("/api/v1/admin/kill-switch", { credentials: "include" });
const data = await res.json();
SandboxLIVE
133/killswitch
PATCH

Set System Kill Switch

National IT + MFA. offline → API 503 (except auth + this admin path); read_only → block writes.

Request schema

application/json
Body
statusstringbody
required

active | read_only | offline

Example JSON body

{
  "status": "active"
}

Example response (200)

{
  "system": {
    "status": "active"
  }
}

Snippet

const res = await fetch("/api/v1/admin/kill-switch/system", {
  method: "PATCH",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ status: "active" }),
});
SandboxLIVE
134/killswitch
PATCH

Set Assembly Kill Switch

National IT; regional IT for assemblies in their region. Tenant offline/read_only enforced by middleware.

Request schema

application/json
Path
codestringpath
required

Assembly code

Body
statusstringbody
required

active | read_only | offline

Example JSON body

{
  "status": "active"
}

Example response (200)

{
  "assembly": {
    "code": "GSMA-01",
    "status": "active"
  }
}

Snippet

const res = await fetch("/api/v1/admin/kill-switch/assemblies/GSMA-01", {
  method: "PATCH",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ status: "active" }),
});
SandboxLIVE