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.

Webhook body · json
{
  "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.

TypeFires when
assessment.createdA fresh assessment is on the rail — useful for MDAs that want to mirror open obligations.
assessment.submittedAssessment locked. Lines are immutable from this point. EFRIS FDNs attached on each taxable line.
assessment.cancelledAssessor cancelled before payment. No money has moved.
payment_intent.initiatedCharge request sent to the aggregator. The citizen's handset is being prompted.
payment_intent.confirmedAggregator confirmed funds. The split disbursement is in flight.
payment_intent.failedAggregator returned a terminal failure (cancellation, insufficient balance, etc.).
payment_intent.refundedA refund has been applied. Always traceable back to the original intent + the refunding actor.
assessment.propagatedA specific MDA line has been propagated to the destination system of record. Multiple events fire on cross-MDA assessments.
shift.openedA counter shift has opened. Carries the opening float, clerk, and MDA.
shift.closedCounter shift sealed with cash count and variance. Variance audit chain attached.
shift.variance_escalatedSupervisor has escalated a variance for Treasurer review.
citizen.createdA new citizen record has been written through the rail (rather than mirrored from NIRA).
citizen.consent_updatedCitizen consent flags changed. PDP audit-trail tied to a specific actor.
catalogue.changedService 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.

python (flask)
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 "", 200

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

python
import time

ts = int(request.headers.get("X-Sente-Timestamp", "0"))
if abs(time.time() - ts) > 300:
    abort(401)  # outside replay window

Idempotency 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 200 within 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:

  1. +30 seconds
  2. +2 minutes
  3. +15 minutes
  4. +1 hour
  5. +4 hours
  6. +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

payment_intent.confirmed · json
{
  "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 }
      ]
    }
  }
}
assessment.propagated (one per MDA on cross-MDA) · json
{
  "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"
    }
  }
}