On this page
Webhooks and events
Instead of polling for changes, a partner system can be told when something happens: an application was submitted, a fee was invoiced, a permit was issued. The platform sends each event as an HTTPS POST to your endpoint, as a CloudEvents 1.0 JSON document, signed with a secret only you and the platform know.
Webhooks carry identifiers and statuses only: a case number, a service id, a status word, a permit number. They never carry an applicant's name, contact details, form answers or officers' notes. When you need more, fetch it with your own token through the Partner API, within your scopes.
How a subscription is set up
- Your client must hold the
events:subscribescope (see Partner API). - You give the owning MDA the HTTPS address of your receiver and the event types you want.
- An administrator of the MDA creates the subscription in the back office (Governance, Webhooks): your client, the
URL, the event types (or
*for all) and a description. The signing secret (whsec_...) is shown once; it is passed to you through a secure channel and kept encrypted on the platform, because the platform needs it to sign. - The administrator sends a
webhook.testevent from the back office. Your receiver should answer2xx.
You can list your own subscriptions with GET /api/v1/webhooks and the event catalogue with GET /api/v1/events. Subscriptions are created, paused, changed and removed only by the MDA's administrators; every change is audited.
Rules for the target URL
- HTTPS only in production. No user name or password in the URL; at most 500 characters.
- The platform refuses internal, loopback and link-local addresses, and checks the address again at every send (a
name that later resolves to an internal address is refused). It never follows a redirect: answer the
POSTitself. - An installation may restrict targets to an approved list of partner networks (
WEBHOOK_ALLOWED_HOSTS). If yours isnot on it, the MDA must add it.
Which events you receive
You receive an event when all of these hold:
- the subscription is active and lists the type (or
*); - your client is active, not expired, and holds
events:subscribe; - the event's ministry is in your client's MDA scope. Three types concern no one's application and go to every
subscriber whatever its MDA scope:
service.published,webhook.testandplatform.module-changed.
Event types
Core event types:
| Type | When | data fields |
|---|---|---|
case.submitted | An application was submitted | caseId, caseNumber, serviceId, status |
case.status_changed | An application moved to a stage the applicant can see | caseId, caseNumber, serviceId, status, statusLabel |
case.returned | An application was returned to the applicant for action | caseId, caseNumber, serviceId, status |
case.decided | An application was approved or rejected | caseId, caseNumber, serviceId, status, decision |
case.completed | An application was completed | caseId, caseNumber, serviceId, status |
payment.invoiced | A fee was requested for an application | caseId, caseNumber, serviceId |
payment.confirmed | A payment for an application was confirmed | caseId, caseNumber, serviceId |
permit.issued | A permit, licence or certificate was issued | caseId, caseNumber, serviceId, permitNumber |
permit.status_changed | A permit was suspended, reinstated, cancelled or revoked | caseId, serviceId, permitNumber, permitStatus |
service.published | A new version of a service was published | serviceId, serviceName, version, previousVersion, isActive |
webhook.test | A test message sent from the back office | message |
platform.module-changed | An optional module was switched on or off, or assigned to other ministries | module, action, enabled, scope |
Most data objects also carry occurredAt. Events are written in the same database transaction as the change they report, so an event exists only if the change committed.
Module events. An optional module can add its own types, always prefixed with its id (for example forestry.licence.issued). A module cannot redefine a core type. A subscription to a module's type stays valid while the module is switched off; it simply receives nothing. GET /api/v1/events lists every type the installation knows, core and module.
Federation events are a different channel. The gsb.fed.v1.* events (for example gsb.fed.v1.case.submitted) travel between a ministry node and the national hub over the signed /fed/v1 contract. They use the same delivery machinery but are not partner webhooks, and partners do not subscribe to them.
What a delivery looks like
POST /hooks/gsb HTTP/1.1
Content-Type: application/cloudevents+json; charset=utf-8
User-Agent: GSB-eServices-Webhooks/1.0
X-GSB-Event-Id: 0b6f5a4e-1c2d-4e5f-9a8b-7c6d5e4f3a2b
X-GSB-Event-Type: case.decided
X-GSB-Delivery-Id: 48213
X-GSB-Signature: t=1792656000,v1=5d3c...e9a1
{
"specversion": "1.0",
"id": "0b6f5a4e-1c2d-4e5f-9a8b-7c6d5e4f3a2b",
"source": "urn:zm:gov:eservices",
"type": "case.decided",
"subject": "EXAMPLE-2026-000123",
"time": "2026-10-22T09:30:00.000Z",
"datacontenttype": "application/json",
"sequence": 4,
"data": {
"caseId": "3f0c...", "caseNumber": "EXAMPLE-2026-000123", "serviceId": "9a1b...",
"status": "Approved", "statusLabel": "Approved", "decision": "approved",
"occurredAt": "2026-10-22T09:30:00.000Z"
}
}
| Field or header | Use it for |
|---|---|
id and X-GSB-Event-Id | The event's identity. The same on every retry and every replay. Drop duplicates by it. |
X-GSB-Delivery-Id | One delivery attempt chain. A replay has a new delivery id but the same event id. Quote it to support. |
subject | The record the event is about (usually the case number). |
sequence | A number per subject, in commit order. Use it to put the events of one application in order. If you subscribe to only some types you will see gaps; the order is what counts. |
source | The installation (urn:zm:gov:eservices unless the installation sets WEBHOOK_SOURCE). |
X-GSB-Signature | Proof the message came from the platform and was not changed. Verify it first. |
Verifying the signature
The header has this form:
X-GSB-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>[,v1=<signature with the previous secret>]
To verify:
- Read the raw request body exactly as received. Do not parse and re-serialise the JSON first: any change of
spacing breaks the signature.
- Split the header on commas. Take
tand everyv1value. - Refuse the message if
tis more than 5 minutes from your clock (this stops replays of an old message). - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lower-case hex. - Accept if any
v1value equals it, compared in constant time. During a secret rotation the platform sends twov1values, one per secret, and either may match.
A Node.js receiver (Express, no other dependency):
// Node 18+. GSB_WEBHOOK_SECRETS: the current secret, and during a rotation also the new one, comma-separated.
const crypto = require('crypto');
const express = require('express');
const SECRETS = String(process.env.GSB_WEBHOOK_SECRETS || '').split(',').map((s) => s.trim()).filter(Boolean);
const TOLERANCE_S = 300;
function signatureValid(header, rawBody, secrets, nowMs = Date.now()) {
const parts = String(header || '').split(',').map((p) => p.trim());
const t = Number((parts.find((p) => p.startsWith('t=')) || '').slice(2));
if (!t || Math.abs(nowMs / 1000 - t) > TOLERANCE_S) return false;
const given = parts.filter((p) => p.startsWith('v1=')).map((p) => Buffer.from(p.slice(3)));
return secrets.some((secret) => {
const want = Buffer.from(crypto.createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(`${t}.`), rawBody]))
.digest('hex'));
return given.some((got) => got.length === want.length && crypto.timingSafeEqual(got, want));
});
}
const app = express();
// express.raw keeps the body as a Buffer: the exact bytes that were signed
app.post('/hooks/gsb', express.raw({ type: '*/*', limit: '256kb' }), async (req, res) => {
if (!signatureValid(req.get('X-GSB-Signature'), req.body, SECRETS)) {
return res.status(401).end(); // a 4xx other than 408/425/429 is not retried by the platform
}
let event;
try { event = JSON.parse(req.body.toString('utf8')); } catch (_) { return res.status(400).end(); }
// Record the event id first; a duplicate is acknowledged and ignored
const isNew = await rememberEvent(event.id); // see "Idempotency" below
if (isNew) queueForProcessing(event); // do the real work after answering
return res.status(204).end();
});
app.listen(8080);
Notes:
- Answer quickly. The platform waits at most 10 seconds (
WEBHOOK_TIMEOUT_MS); a timeout counts as a failure and isretried. Acknowledge first, then process in the background.
- Keep the secrets in your secret store. Never log the signature header together with the body and the secret.
- A valid signature proves the message came from the platform. It does not replace your own checks of what the event
asks you to do: fetch the current state through the API before you act on anything that matters.
Retries and the dead-letter list
| Your answer | What the platform does |
|---|---|
Any 2xx | Delivered. |
Timeout, network error, 408, 425, 429 or any 5xx | Retried after about 1 min, 5 min, 15 min, 1 h, 3 h, 6 h and 12 h (each with up to 10% jitter): about 22 hours in all. |
Any other 4xx (and a 3xx: redirects are not followed) | Dead at once: the receiver refused it. |
Still failing after 8 attempts (WEBHOOK_MAX_ATTEMPTS) | Dead: it goes on the dead-letter list. |
| A URL the outbound guard refuses | Dead at once: retrying will not make the address acceptable. |
Deliveries are at least once: a delivery that timed out on the platform's side may have reached you. Expect duplicates and drop them by event id.
The MDA's administrators see every delivery, its attempts, last status code and error in the back office, grouped by cause on the integration monitoring page.
Replay
When your receiver was down longer than the retry window, or refused events by mistake, ask the MDA to replay them. An administrator can:
- replay one delivery (
POST /api/webhooks/deliveries/{id}/replay); or - replay a group of dead deliveries at once, by subscription and cause (up to 500 per request). A dead delivery that
was already replayed is not replayed twice.
A replay is a new delivery of the same event: a new X-GSB-Delivery-Id, the same event id, the same body, signed afresh with a current timestamp. The original delivery row is kept unchanged. Every replay is audited with the administrator and the reason.
If you need history beyond what webhooks carried, clients with audit:read can also read the audit trail of their MDAs' applications from a sequence number on (GET /api/v1/audit/events?after=<n>), which is designed to be replayed.
Idempotency: process each event once
Because of retries and replays, the same event can arrive more than once. Make processing idempotent:
CREATE TABLE gsb_events_received (
event_id uuid PRIMARY KEY,
type text NOT NULL,
subject text,
sequence integer,
received_at timestamptz NOT NULL DEFAULT now()
);
// pg (node-postgres). Resolves true the first time an event id is seen, false for a duplicate.
async function rememberEvent(id, { type, subject, sequence } = {}) {
const r = await pool.query(
`INSERT INTO gsb_events_received (event_id, type, subject, sequence)
VALUES ($1, $2, $3, $4) ON CONFLICT (event_id) DO NOTHING`,
[id, type || 'unknown', subject || null, Number.isInteger(sequence) ? sequence : null]);
return r.rowCount === 1;
}
- Key on the event
id, not the delivery id. - When order matters, keep the last
sequenceyou applied per subject and ignore an event whose sequence is nothigher. If you see a jump and you subscribe to every type of that subject, fetch the current state through the API instead of guessing.
- Return
2xxfor a duplicate. Returning an error would only make the platform try again.
Rotating the webhook secret
The MDA rotates a subscription's secret with an overlap of 0 to 168 hours (default 24). During the overlap every delivery carries two v1 signatures, one per secret. Add the new secret to your receiver's list as soon as you get it, then remove the old one after the overlap. An overlap of 0 drops the old secret at once; use it when the secret has leaked.
Testing
- Ask the MDA to send
webhook.testto your subscription in the sandbox. - Unit-test your verifier with a fixed secret, a fixed timestamp and a fixed body; test a changed body, an old
timestamp, and a header with two
v1values. - In development the platform sends webhooks right after the change commits (
WEBHOOK_DISPATCH=inline); in productiona worker sends them within a few seconds.
Related: Partner API, Document verification. The course Integration developer has an exercise on signature verification.