You register webhook endpoints in the dashboard. From then on, every
qualifying event in your workspace is delivered to that endpoint as
an HTTP POST. This page explains the request shape, how to verify
it, and how retries work.
Endpoint management (creating, listing, regenerating secrets) is
done from the dashboard. The /v1/* merchant API does not expose
webhook CRUD.
Event types
| Event | Fires when |
|---|
nzube.onye.created | A customer record is created. |
nzube.onye.updated | A customer record is updated. |
nzube.onye.deleted | A customer record is deleted. |
ugwo.created | A new ugwo is created. |
ugwo.updated | An ugwo changes state (e.g. requires_ugwo → processing → ugwo_successful). |
ugwo.activity.created | An activity is created against an ugwo. |
ugwo.activity.updated | An activity changes state (requires_action → successful, etc). |
Delivery request
Every webhook is sent as an HTTP POST with a JSON body.
| Header | Purpose |
|---|
Content-Type | Always application/json. |
X-Kwugwo-Signature | HMAC-SHA256 of the raw body, hex-encoded, computed with your endpoint’s secret. Empty string if you never set a secret. |
X-Kwugwo-Delivery-Id | UUID identifying this delivery attempt. Re-delivery on retry uses a new id. Useful for idempotency on your side. |
Body envelope
The body is an envelope wrapping the resource that triggered the event:
{
"uid": "evt.VCvr.7K2qPmRtV9xLnQ8sD1cYwHfE",
"event": "ugwo.activity.updated",
"data": {
/* The resource snapshot. For ugwo.* events this is an ugwo;
for ugwo.activity.* events this is an activity; for
nzube.onye.* events this is an onye. */
}
}
uid: the event id. It follows Kwugwo’s standard
ID format with the evt prefix and is
stable across redelivery attempts; use it as your
idempotency key.
event: the event type from the table above.
data: the resource snapshot, in the same shape the
corresponding GET endpoint returns.
Verifying the signature
Compute HMAC-SHA256(secret, raw_body) and compare it constant-time
against the X-Kwugwo-Signature header. Verify against the raw
body bytes, not a re-encoded JSON string; re-serializing will
change whitespace and break the signature.
import crypto from "node:crypto";
export function verifyKwugwoSignature(rawBody, header, secret) {
if (!secret) return true; // signature is empty when you've not set a secret
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(header, "hex"),
);
}
import hmac, hashlib
def verify_kwugwo_signature(raw_body: bytes, header: str, secret: str) -> bool:
if not secret:
return True
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
function verify_kwugwo_signature(string $rawBody, string $header, string $secret): bool {
if ($secret === '') return true;
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $header);
}
If your framework reads the body before you do (e.g. Express’s
body-parser), make sure you can still get at the raw bytes.
In Express, attach bodyParser.json({ verify: (req, _res, buf) => req.rawBody = buf })
and verify against req.rawBody.
Source IP
All Kwugwo outbound traffic - both webhook deliveries and any
server-to-server API calls we make on your behalf - originates from
a single static IP:
If your endpoint sits behind an allowlist, whitelist this address so
Kwugwo can reach it. The same IP is used in sandbox and live.
Retries
Kwugwo treats any 2xx response from your endpoint as success. Any
other status, a redirect, a network error, or a timeout is a failure.
| Setting | Value |
|---|
| Max attempts | 3 (the initial delivery + 2 retries) |
| Retry interval | 30 minutes between attempts |
| Backoff | Fixed (no exponential growth) |
| Per-event status | pending → success on a 2xx, or pending again until the cap, then failed |
After the third failed attempt the event is marked failed and is
not retried again. Re-delivery is not yet exposed in the dashboard;
if you need an event replayed, contact support with the event uid.
Best practices
- Respond quickly. Return
200 as soon as you’ve persisted the
event UID. Do the heavy work asynchronously; webhook delivery
treats any timeout as a failure and burns one of your three
attempts.
- Be idempotent on
event.uid. Two attempts of the same event
share the same uid. Two events for the same state change
(e.g. ugwo.updated followed by ugwo.activity.updated) do not.
- Re-fetch on the merchant API for the source of truth. The
data snapshot is what the resource looked like at the moment the
event fired. If your handler runs minutes later (or after a
retry), pull the latest from the merchant API before acting.
- Don’t enforce a list of allowed event types in code. New
events get added over time; just skip the ones you don’t care
about.