An official developer portal of the Government of the Republic of Zambia · Test environmentVerify a certificate
Pamodzi Developer HubBuild, integrate and learn the e-Services framework
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

  1. Your client must hold the events:subscribe scope (see Partner API).
  2. You give the owning MDA the HTTPS address of your receiver and the event types you want.
  3. 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.

  4. The administrator sends a webhook.test event from the back office. Your receiver should answer 2xx.

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 POST itself.

  • An installation may restrict targets to an approved list of partner networks (WEBHOOK_ALLOWED_HOSTS). If yours is

    not 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.test and platform.module-changed.

Event types

Core event types:

TypeWhendata fields
case.submittedAn application was submittedcaseId, caseNumber, serviceId, status
case.status_changedAn application moved to a stage the applicant can seecaseId, caseNumber, serviceId, status, statusLabel
case.returnedAn application was returned to the applicant for actioncaseId, caseNumber, serviceId, status
case.decidedAn application was approved or rejectedcaseId, caseNumber, serviceId, status, decision
case.completedAn application was completedcaseId, caseNumber, serviceId, status
payment.invoicedA fee was requested for an applicationcaseId, caseNumber, serviceId
payment.confirmedA payment for an application was confirmedcaseId, caseNumber, serviceId
permit.issuedA permit, licence or certificate was issuedcaseId, caseNumber, serviceId, permitNumber
permit.status_changedA permit was suspended, reinstated, cancelled or revokedcaseId, serviceId, permitNumber, permitStatus
service.publishedA new version of a service was publishedserviceId, serviceName, version, previousVersion, isActive
webhook.testA test message sent from the back officemessage
platform.module-changedAn optional module was switched on or off, or assigned to other ministriesmodule, 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 headerUse it for
id and X-GSB-Event-IdThe event's identity. The same on every retry and every replay. Drop duplicates by it.
X-GSB-Delivery-IdOne delivery attempt chain. A replay has a new delivery id but the same event id. Quote it to support.
subjectThe record the event is about (usually the case number).
sequenceA 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.
sourceThe installation (urn:zm:gov:eservices unless the installation sets WEBHOOK_SOURCE).
X-GSB-SignatureProof 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:

  1. Read the raw request body exactly as received. Do not parse and re-serialise the JSON first: any change of

    spacing breaks the signature.

  2. Split the header on commas. Take t and every v1 value.
  3. Refuse the message if t is more than 5 minutes from your clock (this stops replays of an old message).
  4. Compute HMAC-SHA256(secret, t + "." + rawBody) as lower-case hex.
  5. Accept if any v1 value equals it, compared in constant time. During a secret rotation the platform sends two

    v1 values, 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 is

    retried. 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 answerWhat the platform does
Any 2xxDelivered.
Timeout, network error, 408, 425, 429 or any 5xxRetried 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 refusesDead 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 sequence you applied per subject and ignore an event whose sequence is not

    higher. 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 2xx for 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.test to 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 v1 values.

  • In development the platform sends webhooks right after the change commits (WEBHOOK_DISPATCH=inline); in production

    a worker sends them within a few seconds.

Related: Partner API, Document verification. The course Integration developer has an exercise on signature verification.