Guide
Webhooks
Event delivery over HTTPS, signed with HMAC-SHA256 so you can prove a request came from RemoteRegister and was not altered.
Availability#
-
Managing subscriptions needs a token with the
webhooks:managescope or an administrator session; see machine integrations for why a token cannot be obtained self-service yet. Subscriptions belong to a tenant and are visible and manageable only by that tenant's administrators (platform super administrators see all). Subscribing to an event name that does not exist is rejected with400. - Subscriptions, delivery logs and dead letters are held in memory in this release. They are lost when the service restarts (for example on a deployment), so re-create subscriptions and reconcile after any restart. Durable storage is planned.
Events#
| Event | Meaning | Emitted by the platform |
|---|---|---|
payout.dispatched | A direct payout batch was submitted to a payment provider and did not fail. | Yes |
attendance.session_sealed | An attendance session was sealed into the audit ledger. | Not yet |
stipend.run_locked | A stipend pay run was locked. | Not yet |
avs.verified | A bank account verification (AVS-R) completed. | Not yet |
tranche.pack_finalized | A tranche evidence pack was finalised. | Not yet |
learner.registered | A learner was registered. | Not yet |
All six names are valid subscription targets, but in this release the platform emits only
payout.dispatched from real activity. The other five can be generated with the test and
simulate endpoints (sample payloads), and will start arriving as the corresponding workflows are
connected. Do not build on an event marked "Not yet". payout.dispatched is delivered only
to the subscriptions of the tenant that made the payout.
The payout.dispatched payload
{
"batchId": "…",
"runId": "…",
"provider": "NETCASH_PAYSHAP",
"providerMode": "live",
"totalDisbursedZar": 112500,
"recipientCount": 25,
"providerBatchReference": "…",
"settlementTimestamp": "2026-10-29T14:03:11.000Z",
"auditHash": "<64-hex ledger hash>"
}
Payloads from POST /webhooks/:id/test and /webhooks/simulate-event are
illustrative and have a different shape (they add eventId and eventType); do
not code against them.
What we send#
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: RemoteRegister-Webhook/1.0
X-Remote-Register-Event: learner.registered
X-Remote-Register-Timestamp: 2026-10-29T14:03:11.482Z
X-Remote-Register-Signature: sha256=3b7c…e91a
{ …the event payload… } - The body is the JSON event payload. Treat the exact bytes as the thing that was signed.
-
X-Remote-Register-Timestampis an ISO 8601 UTC time. There is no delivery-ID header, so make your handler idempotent and deduplicate on the payload's own identifiers (forpayout.dispatched:batchIdwithproviderBatchReference). - We wait up to 10 seconds for your response. Any
2xxis success; everything else (including a timeout) is a failure. - Use an
https://endpoint. The platform will accepthttp://, but a signed payload over plain HTTP is readable in transit.
Verify a delivery#
The signature is sha256= followed by the lowercase hex HMAC-SHA256, keyed with your
subscription secret, of the string <timestamp>.<raw body>. Verify it against the raw
body before parsing the JSON, compare in constant time, and reject old timestamps to stop replays.
The secret (whsec_…) is generated by the server and returned when the subscription is created.
This file is tested in CI against the platform's own signer; it is the code below, unmodified.
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Verify a RemoteRegister webhook delivery.
*
* rawBody the request body exactly as received (a string or Buffer), NOT re-serialised JSON
* headers the request headers (lower-cased keys, as Node and Express provide them)
* secret the subscription secret
* toleranceMs how old a delivery may be (recommended: 5 minutes)
*/
export function verifyWebhook({ rawBody, headers, secret, toleranceMs = 5 * 60 * 1000 }) {
const timestamp = headers['x-remote-register-timestamp'];
const received = headers['x-remote-register-signature'];
if (!timestamp || !received) return false;
// Reject replays of old deliveries.
if (Math.abs(Date.now() - Date.parse(timestamp)) > toleranceMs) return false;
const expected =
'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && timingSafeEqual(a, b);
}
Retries & dead letters#
- A failed delivery is retried up to 3 more times (4 attempts in total) with waits of 250 ms, 500 ms and 1 s between attempts.
- These retries are immediate and short — they ride out a blip, not an outage.
- After the last attempt fails, the delivery is recorded as a dead letter and written to the audit ledger. List them and retry one by hand from the endpoints below.
- Delivery is at least once. Expect the occasional duplicate, especially after a manual retry.
Managing subscriptions#
curl -s https://rr-api.medq.co.zw/api/v1/webhooks \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'content-type: application/json' \
-d '{ "name": "LMS roster sync", "endpointUrl": "https://integrator.example/hooks/rr", "events": ["learner.registered"] }'
Body fields: name, endpointUrl, events (at least one of the events
above) and optionally deliveryMode (LIVE or SIMULATOR).
SIMULATOR never makes an outbound request and its results are labelled as simulated.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/webhooks | webhooks:manage |
| POST | /api/v1/webhooks | webhooks:manage |
| DELETE | /api/v1/webhooks/:id | webhooks:manage |
| POST | /api/v1/webhooks/:id/test | webhooks:manage |
| GET | /api/v1/webhooks/dead-letters | webhooks:manage |
| GET | /api/v1/webhooks/deliveries | webhooks:manage |
| POST | /api/v1/webhooks/deliveries/:id/retry | webhooks:manage |
| POST | /api/v1/webhooks/simulate-event | webhooks:manage |