Guide

Webhooks

Event delivery over HTTPS, signed with HMAC-SHA256 so you can prove a request came from RemoteRegister and was not altered.

Availability#

Preview — read the limits
  • Managing subscriptions needs a token with the webhooks:manage scope 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 with 400.
  • 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#

EventMeaningEmitted by the platform
payout.dispatchedA direct payout batch was submitted to a payment provider and did not fail.Yes
attendance.session_sealedAn attendance session was sealed into the audit ledger.Not yet
stipend.run_lockedA stipend pay run was locked.Not yet
avs.verifiedA bank account verification (AVS-R) completed.Not yet
tranche.pack_finalizedA tranche evidence pack was finalised.Not yet
learner.registeredA learner was registered.Not yet
Only one event is emitted today

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… }

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#

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.

MethodPathScope
GET/api/v1/webhookswebhooks:manage
POST/api/v1/webhookswebhooks:manage
DELETE/api/v1/webhooks/:idwebhooks:manage
POST/api/v1/webhooks/:id/testwebhooks:manage
GET/api/v1/webhooks/dead-letterswebhooks:manage
GET/api/v1/webhooks/deliverieswebhooks:manage
POST/api/v1/webhooks/deliveries/:id/retrywebhooks:manage
POST/api/v1/webhooks/simulate-eventwebhooks:manage