Concepts
API standards
The contract every endpoint on the rail honours. Read once, never wonder again why a response looks the way it does.
Versioning
The API surface is versioned in the URL path — /v1/<resource>. The version moves when an incompatible change ships; additive changes (new optional fields, new endpoints, new query parameters) stay on the current major.
- Stable contract on v1. Existing clients keep working until a stated deprecation window closes — minimum twelve months notice from the day the deprecation page goes up.
- Beta endpoints. Marked with the
X-Sente-Stage: betaresponse header. Field shapes may change without notice while in beta; the URL stays the same when the endpoint stabilises. - Vendor extensions. Custom fields are namespaced under
x_(for examplex_gulu_zone_id) so they never collide with future core additions.
Response envelope
Every successful response wraps its payload in a data field. Lists carry pagination metadata in a sibling pagination object. Errors use a distinct envelope with a error field — never both.
{
"data": {
"name": "ASSESS-2026-000123",
"status": "Assessed",
"total_amount": 50000,
"currency": "UGX"
}
}{
"data": [
{ "short_code": "GULU", "sector": "Local Government" },
{ "short_code": "URA", "sector": "Revenue" }
],
"pagination": {
"limit": 100,
"start": 0,
"total": 46,
"has_more": false
}
}Error envelope
All errors share a stable shape. Match on error.code (machine-readable, stable across releases), not on the message string (human-readable, may evolve).
{
"error": {
"code": "validation_failed",
"message": "Assessment Line for service TL-RENEW is missing required field 'mda'.",
"request_id": "req_2026_05_25_abc123",
"details": [
{ "field": "lines[0].mda", "issue": "required" }
]
}
}The canonical error.code values:
| Code | HTTP | Meaning |
|---|---|---|
| validation_failed | 400 | Request body or query parameters didn't pass schema validation. |
| unauthorized | 401 | Missing or invalid Bearer token. |
| forbidden | 403 | Token valid but insufficient scope for this resource. |
| not_found | 404 | Resource doesn't exist on this site. |
| conflict | 409 | Idempotency-Key collision with a different request body, or state conflict (e.g. assessing a submitted doc). |
| unprocessable | 422 | Schema-valid but semantically rejected (e.g. payment intent with mismatched currency). |
| rate_limited | 429 | Per-integrator throttle exceeded. Retry-After header carries the retry window. |
| upstream_failure | 502 | An MDA-side or aggregator-side adapter returned an error. Inspect details.upstream. |
| upstream_timeout | 504 | Adapter didn't respond inside the per-MDA timeout budget. |
Pagination
List endpoints accept start (offset) and limit (page size). Default limit is 100; maximum is 500. Cursor-based pagination is on the v2 roadmap for endpoints whose total set can exceed ten thousand rows — the catalogue endpoints stay offset-based.
curl "https://sente-rails.space/v1/mdas?start=0&limit=20"Always read pagination.has_more
Don't assume the absence of has_more=true means you've seen everything. Read the flag, not the row count.
Idempotency
Mutating endpoints (POST, PUT, DELETE) accept an Idempotency-Key header. The rail stores the original response under that key for 24 hours; replaying the request with the same key returns the same response without re-executing the side effect.
curl -X POST https://sente-rails.space/v1/assessments \
-H "Authorization: Bearer sk_sandbox_..." \
-H "Idempotency-Key: 7f8e3c1a-2b4d-4e5f-9a8b-1c2d3e4f5a6b" \
-H "Content-Type: application/json" \
-d '{ "citizen": "CM78001234ABCD", "lines": [...] }'- Use a fresh UUID per logical operation.
- Replaying with the same key + a different body returns
409 conflict— the key is bound to its original body. - The aggregator-facing payment endpoints carry idempotency at two layers: this header for our side, plus the aggregator's own mechanism for theirs. Both are required.
Rate limits
Per-integrator throttle: 120 requests / minute per token for read endpoints, 60 / minute for write endpoints, 30 / minute for payment endpoints. Limits are bursty — the bucket refills smoothly inside the minute window, so brief spikes don't trip them.
On throttle, the response is 429 with three response headers:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-Sente-RateLimit-Limit: 120
X-Sente-RateLimit-Remaining: 0Dates, times, and currency
- Datetimes. Always ISO 8601 with a
Zsuffix (UTC). Sample:2026-05-25T08:30:11Z. We never serialise local-tz strings; if you need Africa/Kampala display, do it client-side from the UTC value. - Dates. ISO 8601 date-only:
2026-05-25. - Currency amounts. Stored and transmitted as integer minor units (e.g. UGX 50,000 is sent as
50000). No decimal points on the wire; the unit is carried separately incurrency. - Phone numbers. E.164 format with the leading
+(e.g.+256772123456). The rail rejects locally-formatted numbers (no0772...).
Request IDs
Every response carries an X-Sente-Request-Id header. The same ID is also embedded inside the response envelope for errors. Include it in any support correspondence — we can trace a single ID end-to-end across the rail, the audit log, and any upstream MDA call.
Reference
The complete OpenAPI 3.1 specification — every endpoint, every field, every error path — is browsable in the live API explorer. The raw spec ships in the repository at sente_rails/api/v1/openapi.yaml and is Postman / Insomnia / Bruno importable as-is.