Reference
Webhooks
Outbound events from the rail to your endpoint. Signed with HMAC-SHA256 over the raw body. Idempotent retry semantics. Fourteen event types live today.
Overview
The rail emits webhooks on every meaningful state change — assessments creating, payments confirming, splits propagating, shifts closing. Each delivery is signed, idempotent, and retried on transient failures with exponential backoff for up to seventy-two hours.
Webhooks are configured per integrator via the back-office (today, by emailing asatlabs@gmail.com while self-serve configuration is being wired up). You can register multiple endpoints with disjoint event-type filters.
Event envelope
Every webhook body shares the same outer shape. The data.object field holds the resource snapshot at the moment of the event.
{
"id": "evt_2026_05_25_a1b2c3d4",
"type": "payment_intent.confirmed",
"occurred_at": "2026-05-25T08:30:11Z",
"api_version": "v1",
"data": {
"object": {
"name": "PI-2026-000045",
"status": "Confirmed",
"assessment": "ASSESS-2026-000123",
"payment_channel": "MTN MoMo",
"amount": 350000,
"currency": "UGX",
"paid_at": "2026-05-25T08:30:09Z"
}
}
}Event catalogue
Fourteen event types fire today. The catalogue is additive — new types may appear; existing types do not change shape.
| Type | Fires when |
|---|---|
| assessment.created | A fresh assessment is on the rail — useful for MDAs that want to mirror open obligations. |
| assessment.submitted | Assessment locked. Lines are immutable from this point. EFRIS FDNs attached on each taxable line. |
| assessment.cancelled | Assessor cancelled before payment. No money has moved. |
| payment_intent.initiated | Charge request sent to the aggregator. The citizen's handset is being prompted. |
| payment_intent.confirmed | Aggregator confirmed funds. The split disbursement is in flight. |
| payment_intent.failed | Aggregator returned a terminal failure (cancellation, insufficient balance, etc.). |
| payment_intent.refunded | A refund has been applied. Always traceable back to the original intent + the refunding actor. |
| assessment.propagated | A specific MDA line has been propagated to the destination system of record. Multiple events fire on cross-MDA assessments. |
| shift.opened | A counter shift has opened. Carries the opening float, clerk, and MDA. |
| shift.closed | Counter shift sealed with cash count and variance. Variance audit chain attached. |
| shift.variance_escalated | Supervisor has escalated a variance for Treasurer review. |
| citizen.created | A new citizen record has been written through the rail (rather than mirrored from NIRA). |
| citizen.consent_updated | Citizen consent flags changed. PDP audit-trail tied to a specific actor. |
| catalogue.changed | Service catalogue updated (new service, fee change, retired service). Useful for integrators caching catalogue data. |
Signature verification
Every webhook carries an X-Sente-Signature header that is the HMAC-SHA256 hex digest of the raw request body using the per- endpoint signing secret. Verify before parsing the body.
Verify against the raw body, not the parsed JSON
JSON re-serialisation reorders keys and changes whitespace; the signature is computed against the bytes on the wire. If you parse first and re-serialise, the digest will mismatch. Buffer the raw body before you hand it to your JSON parser.
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["SENTE_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/sente")
def receive():
sig = request.headers.get("X-Sente-Signature", "")
expected = hmac.new(SECRET, request.data, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
# safe to parse:
event = request.get_json()
# ... handle event["type"]
return "", 200Replay protection
Webhook deliveries carry a X-Sente-Timestamp header (UNIX seconds, sent at delivery time). Reject events whose timestamp is more than five minutes off your server clock — that's the rail's replay window. The signature is computed before the timestamp so a replayer can't adjust one without invalidating the other.
import time
ts = int(request.headers.get("X-Sente-Timestamp", "0"))
if abs(time.time() - ts) > 300:
abort(401) # outside replay windowIdempotency on your side
Treat event.id as the dedup key. Persist it on first receipt and check on every subsequent delivery — duplicates can and do happen, both because of network retries and because of the rail's own at-least-once delivery guarantee.
- A retry of the same event always carries the same
id. - A genuinely new state change carries a fresh
id, even when it's about the same resource (e.g. a payment that fails, then re-tries, then succeeds — three events, three IDs). - Acknowledge with HTTP
200within thirty seconds. Any other status is treated as a transient failure and triggers retry.
Retry semantics
If your endpoint responds with anything other than 2xx within thirty seconds, the rail retries with exponential backoff:
- +30 seconds
- +2 minutes
- +15 minutes
- +1 hour
- +4 hours
- +12 hours, repeating up to 72 hours total
After the 72-hour window the event is moved to a dead-letter queue and surfaced on the back-office for manual review. The audit log retains the full delivery history for any event.
Local development
Sandbox webhooks need a publicly-reachable URL. Three common patterns during local development:
- ngrok / cloudflared tunnel. Free tier covers the sandbox traffic volume. Register the tunnel URL as the endpoint and tear it down when you're done.
- Webhook inspector services. webhook.site is fine for visual inspection; copy the signature header and the raw body to verify manually.
- Trigger replay from the back-office. Every event in the audit log can be re-fired against the registered endpoint with one click — useful for repeatedly testing your handler.
Sample payloads
{
"id": "evt_2026_05_25_a1b2c3d4",
"type": "payment_intent.confirmed",
"occurred_at": "2026-05-25T08:30:11Z",
"api_version": "v1",
"data": {
"object": {
"name": "PI-2026-000045",
"assessment": "ASSESS-2026-000123",
"status": "Confirmed",
"amount": 350000,
"currency": "UGX",
"payment_channel": "MTN MoMo",
"aggregator_reference": "MOMO-2026-XYZ",
"paid_at": "2026-05-25T08:30:09Z",
"splits": [
{ "mda": "URSB", "amount": 300000 },
{ "mda": "URA", "amount": 0 },
{ "mda": "GULU", "amount": 50000 }
]
}
}
}{
"id": "evt_2026_05_25_e5f6g7h8",
"type": "assessment.propagated",
"occurred_at": "2026-05-25T08:30:14Z",
"api_version": "v1",
"data": {
"object": {
"assessment": "ASSESS-2026-000123",
"mda": "URSB",
"lines": [
{ "service": "NAME-RESERVE", "fdn": "FDN-2026-N1", "amount": 50000 },
{ "service": "COMPANY-REG", "fdn": "FDN-2026-C1", "amount": 250000 }
],
"destination_reference": "URSB-CERT-2026-7891"
}
}
}