Skip to content
SkanPay Docs

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

  1. Add a route to your server that accepts POST requests, for example https://shop.example.com/webhooks/skanpay. It must be HTTPS and publicly reachable.
  2. In the dashboard, open Connected apps, choose your app, and save that URL as its Webhook URL.
  3. 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 typeSent when
charge.successfulA charge reached SUCCESSFUL. Fulfil the order.
charge.failedA charge reached FAILED. See failure_code.
charge.reversedA successful charge was reversed by the network.
deposit.successfulA top-up reached SUCCESSFUL. The money is available now.
deposit.failedA top-up reached FAILED.
payout.successfulA payout reached the recipient.
payout.failedA payout failed; the hold is back in your available balance.
withdrawal.successfulA withdrawal reached your payout wallet.
withdrawal.failedA 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 to your webhook URL
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.

Header format
SkanPay-Signature: t=<unix timestamp>,v1=<hex HMAC-SHA256>
  1. Read the raw request body as bytes, before any JSON parsing. Parsing and re-serialising changes the bytes and breaks the signature.
  2. Split the header on , and each part on the first = to get t and v1.
  3. Build the signed string: t, then a dot, then the raw body: "1790588408.{"id":...}".
  4. 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.
  5. Compare with v1 using a constant-time comparison.
  6. Reject it if t is 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 attemptNext attempt in
130 s (±10%)
22 min (±10%)
310 min (±10%)
430 min (±10%)
52 h (±10%)
66 h (±10%)
716 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 id in 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.failed must not overwrite an order that is already paid.
  • If in doubt, fetch the current state with GET /v1/charges/:id: it is always authoritative.
Idempotent handling (SQL)
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

Send a correctly signed request to your own endpoint (bash)
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.