Webhooks
Webhooks are how your integration learns that a payment succeeded or failed. They are signed, retried for about a day, and replayable.
Set up your endpoint
- Add a route to your server that accepts
POSTrequests, for examplehttps://shop.example.com/webhooks/skanpay. It must be HTTPS and publicly reachable. - In the dashboard, open Connected apps, choose your app, and save that URL as its Webhook URL.
- Copy the webhook signing secret (
whsec_…) shown when you first save it. You can reveal it again later with your password. Store it on your server, e.g.SKANPAY_WEBHOOK_SECRET.
An app's webhook receives every event caused by that app's API keys, plus events for money you move from the dashboard yourself. It never receives events from a different app on your account.
Events
| Event type | Sent when |
|---|---|
charge.successful | A charge reached SUCCESSFUL. Fulfil the order. |
charge.failed | A charge reached FAILED. See failure_code. |
charge.reversed | A successful charge was reversed by the network. |
deposit.successful | A top-up reached SUCCESSFUL. The money is available now. |
deposit.failed | A top-up reached FAILED. |
payout.successful | A payout reached the recipient. |
payout.failed | A payout failed; the hold is back in your available balance. |
withdrawal.successful | A withdrawal reached your payout wallet. |
withdrawal.failed | A withdrawal failed or was rejected; the hold is released. |
Events are sent only for final statuses. charge.pending, refund.successful and refund.failed are reserved names that are not sent today. Ignore (and acknowledge with 2xx) any event type you don't handle: new types may be added.
Payload
POST /webhooks/skanpay HTTP/1.1
Content-Type: application/json
User-Agent: SkanPay-Webhooks/1.0
SkanPay-Signature: t=1790588408,v1=5f2b0c8e4d...
SkanPay-Event-Id: evt_01J9Z3M2A7B8C9D0E1F2G3H4J5
SkanPay-Event-Type: charge.successful
{
"id": "evt_01J9Z3M2A7B8C9D0E1F2G3H4J5",
"object": "event",
"type": "charge.successful",
"created_at": "2026-09-28T09:30:08.000Z",
"data": {
"object": {
"id": "chg_01J9Z3K8Q4W6XK2M7N5P0R3T8V",
"object": "charge",
"status": "SUCCESSFUL",
"amount": "50000",
"currency": "UGX",
"country": "UG",
"network": "MTN",
"msisdn_masked": "+2567****456",
"reference": "order-9001",
"description": "2 x T-shirt",
"fee": "1250",
"net": "48750",
"provider": "…",
"failure_code": null,
"failure_message": null,
"metadata": { "cart_id": "c_42" },
"created_at": "2026-09-28T09:29:51.000Z",
"updated_at": "2026-09-28T09:30:08.000Z"
}
}
}data.object is the full object (a charge, deposit, payout or withdrawal) as it was when the event happened. Its object field tells you which. created_at on the event is when it happened, not when it was delivered.
Verify the signature
Anyone can POST to your URL. Only a request with a valid SkanPay-Signature came from SkanPay. Always verify before acting.
SkanPay-Signature: t=<unix timestamp>,v1=<hex HMAC-SHA256>- Read the raw request body as bytes, before any JSON parsing. Parsing and re-serialising changes the bytes and breaks the signature.
- Split the header on
,and each part on the first=to gettandv1. - Build the signed string:
t, then a dot, then the raw body:"1790588408.{"id":...}". - Compute HMAC-SHA256 of that string with your signing secret as the key, and hex-encode it. Use the whole secret, including the
whsec_prefix. - Compare with
v1using a constant-time comparison. - Reject it if
tis more than 5 minutes away from your clock. This stops an old, captured delivery from being replayed at you. Keep your server clock synced (NTP).
// server.mjs (npm install express)
// Run: SKANPAY_WEBHOOK_SECRET=whsec_... node server.mjs
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
function verifySkanPaySignature(rawBody, header, secret) {
const parts = Object.fromEntries(
String(header ?? "").split(",").map((pair) => pair.trim().split("=", 2)),
);
const t = Number(parts.t);
if (!Number.isInteger(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody) // the raw bytes, exactly as received
.digest();
const presented = Buffer.from(parts.v1 ?? "", "hex");
return presented.length === expected.length && timingSafeEqual(presented, expected);
}
const app = express();
// express.raw, NOT express.json: the signature covers the raw bytes.
app.post("/webhooks/skanpay", express.raw({ type: "application/json" }), async (req, res) => {
const ok = verifySkanPaySignature(
req.body,
req.get("SkanPay-Signature"),
process.env.SKANPAY_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// At-least-once delivery: ignore an event id you have already handled.
// (Use a unique column in your database; this is where you check it.)
const object = event.data.object;
switch (event.type) {
case "charge.successful":
console.log(`mark order ${object.reference} paid (${object.amount} ${object.currency})`);
break;
case "charge.failed":
console.log(`order ${object.reference} not paid: ${object.failure_code}`);
break;
}
res.sendStatus(200); // acknowledge quickly; do slow work after responding
});
app.listen(8080, () => console.log("listening on :8080"));Respond quickly
Return any 2xx status within 10 seconds. Anything else counts as a failure and is retried: a 3xx (redirects are not followed), a 4xx, a 5xx, or a timeout. Acknowledge first, then do slow work (emails, fulfilment) in the background. The response body is ignored; SkanPay keeps the first 2,000 characters for your debugging.
Retries
A delivery is attempted up to 8 times. The first attempt goes out within about a minute of the event. The waits between attempts are:
| After attempt | Next attempt in |
|---|---|
| 1 | 30 s (±10%) |
| 2 | 2 min (±10%) |
| 3 | 10 min (±10%) |
| 4 | 30 min (±10%) |
| 5 | 2 h (±10%) |
| 6 | 6 h (±10%) |
| 7 | 16 h (±10%) |
That covers about 25 hours, so an endpoint that is down for a working day still receives every event when it comes back. After the last attempt the delivery is marked dead-lettered. You can see every attempt, with the status code and an excerpt of your response, under your app in Connected apps, and replay any delivery from there.
Handle every event exactly once
Delivery is at-least-once: the same event can arrive more than once (for example if your 200 was lost on the way back), and events can arrive out of order. Make your handler idempotent:
- Record each processed event
idin a table with a unique constraint, in the same database transaction as your order update. If the insert fails, you have already handled it: return 200. - Never let an event move an order backwards. A
charge.failedmust not overwrite an order that is already paid. - If in doubt, fetch the current state with
GET /v1/charges/:id: it is always authoritative.
CREATE TABLE skanpay_events (event_id TEXT PRIMARY KEY, received_at TIMESTAMPTZ NOT NULL DEFAULT now());
BEGIN;
INSERT INTO skanpay_events (event_id) VALUES ('evt_01J9Z3M2...'); -- fails if seen before
UPDATE orders SET status = 'paid', skanpay_charge_id = 'chg_01J9Z3K8...'
WHERE reference = 'order-9001' AND status = 'awaiting_payment';
COMMIT;Testing your endpoint
From your laptop
Webhook URLs must be public HTTPS. During development, expose your local server with a tunnel such as cloudflared tunnel --url http://localhost:8080 or ngrok http 8080, and save the tunnel's https URL as the app's webhook URL. Remember to change it back.
Replaying
Make a small real charge to your own phone, then use replay on its delivery in the dashboard as many times as you need while you fix your handler. A replay goes to the app's current webhook URL and carries the same event id, so it also tests your duplicate handling.
Signing a test request yourself
SECRET="$SKANPAY_WEBHOOK_SECRET"
BODY='{"id":"evt_test_1","object":"event","type":"charge.successful","created_at":"2026-09-28T09:30:08.000Z","data":{"object":{"id":"chg_test","object":"charge","status":"SUCCESSFUL","amount":"500","currency":"UGX","reference":"order-test"}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:8080/webhooks/skanpay \
-H "Content-Type: application/json" \
-H "SkanPay-Signature: t=$T,v1=$SIG" \
--data "$BODY"Security checklist
- Verify the signature and timestamp on every request; respond 400 if either fails.
- Keep the signing secret out of source control. There is no one-click rotation yet: if it leaks, remove the app and connect it again (which also issues new API keys), or contact SkanPay support.
- Don't trust the payload for anything you can't check: compare amount and currency with your order.
- Your webhook URL needs no authentication of its own; the signature is the authentication.