GovRate REST documentation
Explore system probes and Sprint endpoints. Check live / offline services below, then use the sandbox against your running API.
Health Check
Liveness probe. Returns OK if the API process is running.
Request schema
application/jsonNo 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);Runtime Config
Port/appUrl from `.env` plus integration liveliness (live / test / mock / attention) — no secret values.
Request schema
application/jsonNo 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);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/jsonNo 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);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/jsonObject key from a prior presign response
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();Readiness Check
Readiness probe. Confirms PostgreSQL connectivity (Neon).
Request schema
application/jsonNo 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);Tenancy Host Info
Portal host map (ADR 006 / GAP-031): marketing, app, hq, region, assembly under TENANCY_BASE_DOMAIN.
Request schema
application/jsonNo 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();Resolve Host Tenant
Uses Host / X-Forwarded-Host to resolve marketing | app | hq | region | assembly and portal URLs.
Request schema
application/jsonNo 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();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/jsonUsername or email
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();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/jsonNo 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();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/jsonUsername or email
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" }),
});Set Field Officer Quick PIN
Authenticated field_officer. Confirms with account password, then stores bcrypt of 4-digit PIN.
Request schema
application/jsonNew 4-digit PIN
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!" }),
});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/jsonChallenge id from enrollment login response
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();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/jsonChallenge id from MFA login response
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();Taxpayer OTP / SMS Info
SMS provider (console|http). Set SMS_PROVIDER=http + SMS_HTTP_URL for a real gateway (GAP-001).
Request schema
application/jsonNo 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();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/jsonRegistered taxpayer phone (or use ghanaCardNumber)
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);Verify Taxpayer OTP
Exchanges a valid OTP challenge for an HTTP-only session cookie.
Request schema
application/jsonChallenge id from otp/request
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" }),
});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/jsonNo 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 displayLogout
Clears the govrate_session cookie. Writes auth.logout audit when a session is present (GAP-020).
Request schema
application/jsonNo request parameters (cookie / empty body).
Example response (204)
null
Snippet
await fetch("/api/v1/auth/logout", {
method: "POST",
credentials: "include",
});Check Sub-Permission
Example gated route requiring users:provision. Returns 200 for local IT, 403 for field officers.
Request schema
application/jsonNo 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();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/jsonNo 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();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/jsonGhana Card PIN GHA-#########-#
First name on card
Last name on card
YYYY-MM-DD
YYYY-MM-DD
Selfie as data:image/jpeg;base64,… (replace with a real face photo for biometric match)
Liveness check passed (false = still photo / fail)
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();Poll KYC Verification
Taxpayer-only. Polls VerifyGH by referenceId and syncs local status when the webhook did not arrive.
Request schema
application/jsonreferenceId 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();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/jsonNo 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();List Properties
Staff: assembly-scoped list. Taxpayer: own properties only. Optional zoneCode filter.
Request schema
application/jsonOptional 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();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/jsonOptional UPN; omit for server auto-generate
residential | commercial | industrial | mixed_use
completed | uncompleted | abandoned
Zone code
Rateable value GHS
Digital address — geocoded to lat/lng when coords omitted
Latitude (optional if ghanaPostGps set)
Longitude (optional if ghanaPostGps set)
Optional landmark / street note
If true and only lat/lng given, reverse-fill digital address
Required for national/regional staff creating into an MMDA
Optional property photo URL
Optional taxpayer user UUID
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",
}),
});Claim Preview (Taxpayer)
Limited fields for UPN claim. claimable when unclaimed + KYC PASSED. May return pending_review while awaiting staff approve.
Request schema
application/jsonProperty 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",
});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/jsonProperty 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",
});My Claims (Taxpayer)
List own property claims for the dashboard tracker (GAP-036).
Request schema
application/jsonNo 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" });List Claims (Staff)
Assembly/region-scoped claim queue. Filter status=pending_review.
Request schema
application/jsonpending_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" });Approve Claim (Staff)
Links ownerId to taxpayer and marks claim approved. First approve may set home MMDA (ADR 004).
Request schema
application/jsonClaim 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" }),
});Reject Claim (Staff)
Marks claim rejected; property stays unlinked.
Request schema
application/jsonClaim 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" }),
});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/jsonUnique 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();Update Property
Staff write roles. Partial update — send only fields to change. Optional lat+lng relocates the PostGIS point.
Request schema
application/jsonPIN to update
New rateable value GHS
Zone code
residential | commercial | industrial | mixed_use
completed | uncompleted | abandoned
Landmark / street note
New latitude (with lng)
New longitude (with lat)
New digital address (may re-geocode)
Optional photo URL
Taxpayer user UUID (or null to clear)
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" }),
});Soft-Delete Property
Staff write roles. Sets is_active=false (PIN remains reserved).
Request schema
application/jsonPIN 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",
});Properties in Bounding Box
PostGIS ST_Intersects envelope query. Staff only. Optional assemblyId for national/regional.
Request schema
application/jsonWest
South
East
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" });GhanaPOST Geocode Info
Provider status. Official GhanaPOST has no public API; default is SperixLabs community geocoder.
Request schema
application/jsonNo 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();Geocode GhanaPOST Address
Digital address → WGS84 center lat/lng (auth required).
Request schema
application/jsonGhanaPOST 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" }),
});Reverse Geocode to GhanaPOST
lat/lng → nearest digital address (auth required).
Request schema
application/jsonLatitude
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 }),
});List Valuation Rules
Assembly rate imposts by zone, category, and effective year. Filter status (GAP-029).
Request schema
application/jsonFiscal / effective year
Optional zone filter
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" });Valuation Rule Approval Info
Statuses and maker/approver roles for rate impost Maker-Checker (GAP-029).
Request schema
application/jsonNo 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();Create Valuation Rule
Staff/IT Maker. Defaults to pending_approval (or status: draft). Unique on assembly + zone + category + year (GAP-029).
Request schema
application/jsonOptional for national/regional; local staff use session assembly
Zone code
Property category
Multiplier (e.g. 0.0018)
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 }),
});Submit Valuation Rule
draft or rejected → pending_approval (GAP-029).
Request schema
application/jsonRule 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",
});Approve Valuation Rule
Executive/IT Checker. Cannot approve own Maker submission. Enables calculate/billing (GAP-029).
Request schema
application/jsonRule 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",
});Reject Valuation Rule
Executive/IT Checker. Optional reason. No self-reject (GAP-029).
Request schema
application/jsonRule UUID
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" }),
});Update Valuation Rule
Staff/IT. Patch rateImpost and/or zoneCode. Approved/rejected rules return to pending_approval (GAP-029).
Request schema
application/jsonRule UUID from list
New multiplier
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 }),
});Compute Tax Liability
Amount Due = Rateable Value × Rate Impost. Pass pin OR assemblyId+zoneCode+category+rateableValue.
Request schema
application/jsonProperty PIN (loads RV/zone/category). Prefer this OR the fields below.
Assembly UUID (required if no pin)
Zone code (required if no pin)
residential | commercial | industrial | mixed_use
Assessed RV in GHS (required if no pin)
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 }),
});Generate Bills (Batch)
Queue mass bill creation for active non-exempt properties (RV × impost). Poll GET /billing/batches/:id.
Request schema
application/jsonOptional for national/regional; local staff use session assembly
Fiscal year
YYYY-MM-DD
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" }),
});Poll Billing Batch Status
Returns created/updated/skipped/failed counts and per-PIN results.
Request schema
application/jsonbatchId 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" });List Bills
Filter by pin and/or fiscalYear.
Request schema
application/jsonProperty PIN
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" });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/jsonNo 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");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/jsonSingle bill UUID (mutually exclusive with billIds)
JSON array of bill UUIDs for pay-all / multi-select (mutually exclusive with billId)
GHS partial — single billId only
MoMo wallet (Paystack test: 0551234987 only)
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" }),
});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/jsonJSON array of unpaid bill UUIDs (1..50)
MoMo wallet (Paystack test: 0551234987 only)
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",
}),
});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/jsonFrom initialize response
success | failed
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" }),
});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/jsonPaystack event name
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" },
}),
});Payment History
Rich history with pin, fiscalYear, receiptAvailable. Taxpayer own / staff scoped (GAP-037).
Request schema
application/jsonFilter by UPN
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" });Taxpayer Credit Wallet
Non-withdrawable overpayment credit + ledger. Applied via initialize useCredit (GAP-038).
Request schema
application/jsonNo request parameters (cookie / empty body).
Example response (200)
{
"creditBalance": 50,
"withdrawable": false,
"ledger": []
}Snippet
const res = await fetch("/api/v1/payments/credit", { credentials: "include" });List Payments / Reconciliation
Taxpayer: own. Staff: assembly-scoped. Filter billId / pin / verificationStatus=pending for Checker inbox.
Request schema
application/jsonBill UUID
Property PIN
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" });Get Payment
Single payment row (taxpayer own / staff scoped).
Request schema
application/jsonPayment 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();Verify Payment (Checker)
municipal_staff / municipal_executive. Cannot verify own Maker payment. Credits bill ledger.
Request schema
application/jsonPending 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" });Reject Payment (Checker)
Reject pending payment; no bill credit. Cannot reject own Maker row.
Request schema
application/jsonPending payment UUID
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" }),
});Download Payment Receipt (PDF)
Verified payments only. Returns application/pdf.
Request schema
application/jsonPayment 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();Notifications Channel Info
SMS / email / Web Push providers (GAP-025). Exposes VAPID public key when configured.
Request schema
application/jsonNo 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();List My Notifications
In-app inbox for the session user.
Request schema
application/jsonOnly 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();Register Web Push Subscription
Store browser PushSubscription keys for VAPID delivery.
Request schema
application/jsonPushService endpoint
p256dh key
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(…) },
}),
});Announcement Scopes Info
national | regional | assembly scopes (GAP-032) + topics rates|gra|assemblies (GAP-040).
Request schema
application/jsonNo 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");Public Announcements Feed
No auth. Marketing site / app feed of published news (default scope=national). Optional topic filter (GAP-040).
Request schema
application/jsonnational | regional | assembly
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");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/jsonnational | regional | assembly
Headline
Card teaser (≤512)
Full message body
Optional HTTPS thumb URL
rates | gra | assemblies
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,
}),
});Mark Notification Read
Mark a single inbox item as read (session owner only).
Request schema
application/jsonNotification 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",
});Mark All Notifications Read
Mark every unread inbox item for the session user.
Request schema
application/jsonNo request parameters (cookie / empty body).
Example response (200)
{
"ok": true,
"markedRead": 3
}Snippet
await fetch("/api/v1/notifications/read-all", {
method: "POST",
credentials: "include",
});Remove Web Push Subscription
Delete by PushService endpoint for the session user.
Request schema
application/jsonSame 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 }),
});List Announcements
Published news for an assembly. Staff may pass includeDrafts=true.
Request schema
application/jsonDefaults to session assembly when set
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();Publish Announcement
Publish a draft and notify assembly audience (SMS/email/push + inbox).
Request schema
application/jsonDraft 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",
});Marketing News Info
External RSS tabs for Figma-style marketing feed (GAP-041). Separate from staff announcements.
Request schema
application/jsonNo request parameters (cookie / empty body).
Example response (200)
{
"tabs": [
"rates",
"gra",
"assemblies",
"headlines"
]
}Snippet
const res = await fetch("/api/v1/marketing-news/info");Public Marketing News Feed
No auth. Publisher stories with outbound url + optional imageUrl. Filter by tab.
Request schema
application/jsonrates | gra | assemblies | headlines
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");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/jsonExample 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}` },
});Analytics Info
KPI endpoints and scope rules for dashboards.
Request schema
application/jsonNo 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");Revenue & Collection Summary
Amount due/paid, collection rate, bill status counts, verified payments, open disputes. Scoped by role.
Request schema
application/jsonFiscal year
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" });Property Distribution
Active properties by category and zone with rateable value sums.
Request schema
application/jsonOptional 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" });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/jsonProperty PIN
image/jpeg | image/png | image/webp | image/gif
facade | structure | interior | other
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 });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/jsonProperty PIN
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();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/jsonProperty PIN
Existing active media UUID
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" }),
});Replace Property Image (complete)
After PUT to replace upload URL — swaps object key and deletes the old blob.
Request schema
application/jsonProperty PIN
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",
});Set Primary Property Image
Marks this active gallery image as primary (`property.imageUrl` = object key). Gallery can still hold many images.
Request schema
application/jsonProperty PIN
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",
});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/jsonProperty PIN
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();Delete Property Media
Staff or owning taxpayer. Deletes storage object + property_media row (204). Reassigns primary if needed.
Request schema
application/jsonProperty PIN
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",
});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/jsonimage/jpeg | image/png | image/webp | image/gif
avatar | selfie | other
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 });Complete Profile Media Upload
Activates profile media after PUT. Completing a new avatar marks prior avatars as replaced.
Request schema
application/jsonMedia 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();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/jsonActive media UUID
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" }),
});Replace Profile Pic (complete)
After PUT — swaps object key, deletes old blob, keeps the same media id.
Request schema
application/jsonMedia 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",
});List My Profile Media
Active profile media + signed GET URLs for the session user.
Request schema
application/jsonNo 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();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/jsonTarget 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();Delete Profile Media
Taxpayer or field_officer. Deletes own profile object + user_media row (204).
Request schema
application/jsonMedia UUID
Example response (204)
null
Snippet
const res = await fetch(`/api/v1/users/me/media/${mediaId}`, {
method: "DELETE",
credentials: "include",
});Office Locations Info
GAP-022 — MMDA / region / national head-office GPS kinds.
Request schema
application/jsonNo 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();List My Offices
Duty/home assembly office + its region + national HQ (GAP-022).
Request schema
application/jsonNo 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();Get Assembly Office
MMDA head-office GhanaPOST / coords.
Request schema
application/jsonAssembly 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();Update Assembly Office
Local IT / municipal write (own MMDA), regional IT (region), national IT (any). Same geocode rules as properties.
Request schema
application/jsonAssembly code
Digital address
Landmark / building name
Latitude (with lng)
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();Get Region Office
Regional coordinating council / office coords.
Request schema
application/jsonRegion 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();Update Region Office
Regional IT (own region) or national IT. Same geocode rules as properties.
Request schema
application/jsonRegion code
Landmark / building name
Latitude (with lng)
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();Get National Office
National HQ from system_config (GAP-022).
Request schema
application/jsonNo 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();Update National Office
National IT only.
Request schema
application/jsonLandmark
Latitude
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();Offline Sync Queue Info
Driver (memory|redis/BullMQ), mutation types, optimistic conflicts (GAP-012), and media blobs (GAP-013).
Request schema
application/jsonNo 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();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/jsonClient-generated batch id (idempotency key per user)
Optional for national/regional staff
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",
},
}],
}),
});Poll Sync Batch Status
Returns queued|processing|completed|partial|failed plus per-mutation results.
Request schema
application/jsonbatchId 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();Disputes Info
State machine: open → under_review → resolved.
Request schema
application/jsonNo 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();File Dispute (Taxpayer)
Taxpayer only. Create dispute on an owned PIN. Flags unpaid/partial bills as disputed.
Request schema
application/jsonOwned property PIN (or use propertyId)
Optional property UUID instead of pin
Short subject
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",
}),
});List Disputes
Taxpayer: own tickets. Staff: assembly inbox (filter status/pin).
Request schema
application/jsonopen | under_review | resolved
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" });Get Dispute
Taxpayer (own) or staff (assembly/region scoped).
Request schema
application/jsonDispute 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" });Advance Dispute Status (Staff)
Staff only. open → under_review → resolved. Invalid skips return 409. Resolving restores bill status.
Request schema
application/jsonDispute UUID
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" }),
});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/jsone.g. mutation | auth.login.success | auth.logout
LOGIN | LOGOUT | MFA | OTP | FAIL | INSERT | UPDATE | …
Filter (national/regional only within scope)
Filter (national/supervisory)
Filter actor (not for self-only roles beyond self)
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();Tenant Admin Info
Region/assembly types and write roles (GAP-028). Status changes stay on Kill Switch.
Request schema
application/jsonNo 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();List Regions
Scoped: national/supervisory see all; others see their region.
Request schema
application/jsonSearch 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();Create Region
national_it_admin only.
Request schema
application/jsonUnique region code
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" }),
});Get Region
By code or UUID; scoped.
Request schema
application/jsonRegion 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();Patch Region
national_it_admin; update code and/or name.
Request schema
application/jsonRegion code or UUID
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" }),
});List Assemblies
Scoped MMDA list. Filter with regionId or regionCode (national).
Request schema
application/jsonFilter 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();Create Assembly
national_it_admin or regional_it_admin (own region). Provide regionId or regionCode.
Request schema
application/jsonParent region code (or use regionId)
Unique assembly / MMDA code
Display name
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",
}),
});Get Assembly
By code or UUID; scoped.
Request schema
application/jsonAssembly 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();Patch Assembly
Update name/type/currency/code. regionId move = national only. Status via Kill Switch.
Request schema
application/jsonAssembly code or UUID
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" }),
});Taxpayer Onboard Info
Field-assisted onboarding roles (GAP-030). Taxpayers use OTP login — no password.
Request schema
application/jsonNo 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();Lookup Taxpayer
Find existing taxpayer by phoneNumber or ghanaCardNumber before onboard.
Request schema
application/jsonTaxpayer 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",
});Onboard Taxpayer (Assisted)
Field officer / municipal staff / local IT creates OTP-only taxpayer. Home MMDA = session assembly (ADR 004 / GAP-030).
Request schema
application/jsonLegal / display name
Unique phone for OTP login
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",
}),
});User Admin Info
Provision hierarchy for national / regional / local IT.
Request schema
application/jsonNo 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();List Users
IT admins. Scoped by hierarchy (local → assembly staff; regional → local IT; national → broad).
Request schema
application/jsonOptional role filter
true | false
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();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/jsonTarget role (must be creatable by caller)
Display name
Unique login id
Required .gov.gh for staff/IT roles
Unique phone
Min 8, letter + digit
Required when national/regional creates local_it_admin
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!",
}),
});Update User
Profile, sub_permissions, suspend (isActive=false). Cannot deactivate self.
Request schema
application/jsonUser UUID
Suspend / reactivate
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 }),
});Reset User Password
IT admin sets a new password and bumps tokenVersion so outstanding session cookies are revoked immediately (GAP-027).
Request schema
application/jsonUser UUID
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!" }),
});Kill Switch Info
Status enum (active | read_only | offline) and admin paths.
Request schema
application/jsonNo 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();Get Kill Switch Status
National / regional / supervisory. System status + assembly list (regional scoped to region).
Request schema
application/jsonNo 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();Set System Kill Switch
National IT + MFA. offline → API 503 (except auth + this admin path); read_only → block writes.
Request schema
application/jsonactive | 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" }),
});Set Assembly Kill Switch
National IT; regional IT for assemblies in their region. Tenant offline/read_only enforced by middleware.
Request schema
application/jsonAssembly code
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" }),
});