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: beta response 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 example x_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.

Single-resource success · json
{
  "data": {
    "name": "ASSESS-2026-000123",
    "status": "Assessed",
    "total_amount": 50000,
    "currency": "UGX"
  }
}
List success · json
{
  "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 response · json
{
  "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:

CodeHTTPMeaning
validation_failed400Request body or query parameters didn't pass schema validation.
unauthorized401Missing or invalid Bearer token.
forbidden403Token valid but insufficient scope for this resource.
not_found404Resource doesn't exist on this site.
conflict409Idempotency-Key collision with a different request body, or state conflict (e.g. assessing a submitted doc).
unprocessable422Schema-valid but semantically rejected (e.g. payment intent with mismatched currency).
rate_limited429Per-integrator throttle exceeded. Retry-After header carries the retry window.
upstream_failure502An MDA-side or aggregator-side adapter returned an error. Inspect details.upstream.
upstream_timeout504Adapter 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.

bash
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.

bash
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
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-Sente-RateLimit-Limit: 120
X-Sente-RateLimit-Remaining: 0

Dates, times, and currency

  • Datetimes. Always ISO 8601 with a Z suffix (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 in currency.
  • Phone numbers. E.164 format with the leading + (e.g. +256772123456). The rail rejects locally-formatted numbers (no 0772...).

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.