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

Webhooks and events

Polling for changes wastes your rate limit and still arrives late. In this lesson you receive events from the platform as signed webhooks, verify them, and process each one exactly once even though it may arrive several times.

What you receive

When something happens to an application, a payment or a permit, the platform sends an HTTPS POST to your receiver. The body is a CloudEvents 1.0 JSON document (Content-Type: application/cloudevents+json):

{
  "specversion": "1.0",
  "id": "0b6f5a4e-1c2d-4e5f-9a8b-7c6d5e4f3a2b",
  "source": "urn:zm:gov:eservices",
  "type": "permit.issued",
  "subject": "EXAMPLE-2026-000123",
  "time": "2026-10-22T09:30:00.000Z",
  "datacontenttype": "application/json",
  "sequence": 6,
  "data": { "caseId": "3f0c...", "caseNumber": "EXAMPLE-2026-000123", "serviceId": "9a1b...", "permitNumber": "EXAMPLE/2026/00042" }
}

The data carries identifiers and statuses only. To learn more, call the Partner API with your own token, inside your scopes.

Core event types: case.submitted, case.status_changed, case.returned, case.decided, case.completed, payment.invoiced, payment.confirmed, permit.issued, permit.status_changed, service.published, webhook.test and platform.module-changed. Optional modules add their own types, always prefixed with the module id (for example forestry.licence.issued). GET /api/v1/events lists every type an installation knows.

The headers matter too:

HeaderMeaning
X-GSB-Event-IdThe event id; the same on every retry and replay
X-GSB-Event-TypeThe type
X-GSB-Delivery-IdThis delivery; a replay gets a new one
X-GSB-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

How you get subscribed

Your client needs the events:subscribe scope. You give the MDA your HTTPS receiver address and the types you want; an administrator creates the subscription in the back office (Governance, Webhooks). The signing secret (whsec_...) is shown once. The administrator then sends a webhook.test event.

You receive an event only when the subscription is active and lists its type, your client is active and holds the scope, and the event's ministry is in your client's MDA scope. Three types go to every subscriber whatever its MDA scope, because they are about no one's application: service.published, webhook.test, platform.module-changed.

The receiver address must be HTTPS in production, must not be an internal or loopback address, and must answer the POST itself: the platform never follows a redirect.

Verifying the signature

The platform signs "<t>.<raw body>" with HMAC-SHA256 under your subscription's secret. To verify:

  1. keep the raw body bytes (do not parse and re-serialise first);
  2. read t and every v1 from the header;
  3. refuse if t is more than 5 minutes from your clock;
  4. compute the HMAC of t + "." + rawBody as hex;
  5. accept if any v1 matches, compared in constant time.
const crypto = require('crypto');

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) > 300) 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));
  });
}

// Express: express.raw keeps req.body as 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();
  const event = JSON.parse(req.body.toString('utf8'));
  if (await rememberEvent(event)) queueForProcessing(event);
  res.status(204).end();
});

Why several v1 values? During a secret rotation (an overlap of 0 to 168 hours, 24 by default) the platform signs with both the old and the new secret, so you can switch without losing a message. Keep both secrets in your list until the overlap ends.

Retries

Your answerResult
2xxDelivered
Timeout (10 s), network error, 408, 425, 429, 5xxRetried after about 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h
Any other 4xx, or a redirectDead at once
Still failing after 8 attemptsDead (dead-letter list)

So: answer 2xx fast and do the work afterwards; answer 401 to a bad signature (it will not be retried); answer 503 if your database is briefly down (it will be).

Replay

When your receiver was down beyond the retry window, the MDA's administrator can replay one dead delivery, or a group of dead deliveries by subscription and cause (up to 500 at a time). A replay is a new delivery of the same event: new delivery id, same event id, same body, a fresh signature. Every replay is audited.

Idempotency and order

Delivery is at least once. A delivery that timed out on the platform's side may have reached you, and replays send events you may already have. Store each event id with a unique constraint and do the work only the first time:

INSERT INTO gsb_events_received (event_id, type, subject, sequence)
VALUES ($1, $2, $3, $4) ON CONFLICT (event_id) DO NOTHING;
-- rowCount 1: new, process it. rowCount 0: duplicate, acknowledge and ignore.

For order, use sequence: a number per subject (usually per application) in commit order. Keep the last sequence you applied per subject and ignore anything not higher. If you subscribe to only some types you will see gaps, which is normal; when you need certainty, fetch the current state through the API.

Federation events are something else

Between a ministry node and the national hub, the platform also sends gsb.fed.v1.* events over the signed /fed/v1 federation contract. They reuse the same delivery machinery but are a node-to-hub channel; partners do not subscribe to them.

Exercise

Write a unit test for signatureValid with a fixed secret and body. Prove that it accepts a correct header, refuses a changed body, refuses a timestamp 10 minutes old, and accepts a header with two v1 values where only the second matches.

Check your understanding

  • What does a webhook carry, and where do you get the details it leaves out?
  • Which three event types reach a subscriber whatever its MDA scope?
  • Why must you verify the signature over the raw body rather than over parsed JSON?
  • Which of your answers make the platform retry, and which end the delivery at once?
  • What stays the same between a delivery and its replay, and what changes?
  • How do you use sequence to keep the events of one application in order?

Sign in to record your progress.

Previous: The Partner API and OAuth client credentials Next: Verifying issued documents