ZonmPay API v2

Webhook API

Real-time events, signed and retried.

WEBHOOK · POST to youWe POST to your URL when a payment reaches a final state. Route on event, not on status.

Authentication

Every request is authenticated with your secret key in the apiToken header. There is one base URL and one key: your account behaves as a sandbox until go-live is approved, on the same URL. Keep the key server-side only, and note that rotating it expires the previous one immediately.

Required Headers

apiToken

sp_sk_prod_XXXXXXXXXXXX

Content-Type

application/json

Overview

Only outcomes notify you: payment.confirmed, payment.failed, payment.refunded, payout.approved, payout.confirmed and payout.failed. Intermediate states such as PROCESSING fire nothing, so do not build a state machine that waits for an "in progress" webhook. You may register up to 3 URLs and every one receives every matching event, so each handler must be independently idempotent. We retry up to 5 times with backoff (1s, 5s, 30s, 2m, 10m) until you return HTTP 2xx.

payment.confirmed — Collection

Fires when a collection reaches SUCCESSFUL, PAID or PARTIAL. amount is the amount actually paid, which can be less than the amount requested on a FLEXIBLE collection.

json
{
  "timestamp": "20260818081604",
  "reference": "SP008985",
  "customerReference": "ORDER-001",
  "amount": 50000,
  "merchantAccountId": "Duka la Mtandaoni",
  "status": "SUCCESSFUL",
  "type": "CHARGE",
  "customer": {
    "firstname": "JOHN",
    "lastname": "HAULE",
    "phoneNumber": "255712345678",
    "mno": "Vodacom"
  },
  "transactionId": "SP250001234567",
  "event": "payment.confirmed",
  "service": "Collection"
}

payment.failed — Collection

Fires when a collection reaches FAILED, REJECTED, CANCELLED or expires. failureReason is present only on *.failed events.

json
{
  "timestamp": "20260818081734",
  "reference": "SP008985",
  "customerReference": "ORDER-001",
  "amount": 0,
  "status": "FAILED",
  "type": "CHARGE",
  "failureReason": "TIMEOUT",
  "customer": { "phoneNumber": "255712345678", "mno": "Vodacom" },
  "event": "payment.failed",
  "service": "Collection"
}

payout.confirmed / payout.failed

payout.approved fires on approval, then payout.confirmed or payout.failed on the final outcome. A failed payout is refunded to your balance before the webhook fires.

json
{
  "timestamp": "20260818090012",
  "reference": "SP009142",
  "customerReference": "PAY-2001",
  "amount": 50000,
  "status": "SUCCESSFUL",
  "type": "PAYOUT",
  "customer": {
    "firstname": "ASHA",
    "lastname": "MWINCHUMU",
    "phoneNumber": "255684118011",
    "mno": "Airtel"
  },
  "transactionId": "SP250001234999",
  "event": "payout.confirmed",
  "service": "Disbursement"
}

Webhook Secret

Every webhook you register gets its own signing secret (starts with whsec_). It is not your API key: you can re-view it at any time, and rotating your API key does not affect it.

  • Where to find it: Dashboard → Settings → Developer → Webhook URL. Each registered URL has its own secret with a copy button, and you may register up to 3 URLs.
  • How the signature is built: HMAC-SHA256 of the raw request body, keyed by the webhook signing secret. The header format is X-SpeedPesa-Signature: sha256=<hex>. Recompute it over the exact raw bytes you received — do not re-serialize the parsed JSON, because key order and spacing must match — then compare in constant time.
  • Keep the secret on your server only (environment variable) — never in browser JavaScript. Return HTTP 2xx once you have stored the event: we retry up to 5 times with backoff (1s, 5s, 30s, 2m, 10m), so every handler must be idempotent.
headers
X-SpeedPesa-Event: payment.updated
X-SpeedPesa-Timestamp: 1754000000
X-SpeedPesa-Signature: t=1754000000,v1=9f2c…  (HMAC-SHA256(timestamp + "." + body, whsec_…))

Verifying the Signature

Every request carries a header X-SpeedPesa-Signature with an HMAC SHA-256 of the raw request body, keyed by your webhook signing secret. Always verify it before processing the payment.

php
<?php
// Signing secret: Dashboard -> Settings -> Webhooks (whsec_...)
$secret = getenv('SPEEDPESA_WEBHOOK_SECRET');

// Raw body: do NOT re-serialize the parsed JSON, byte order matters.
$raw    = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SPEEDPESA_SIGNATURE'] ?? ''; // t=<unix>,v1=<hex>

preg_match('/t=([^,]+)/', $header, $tm);
preg_match('/v1=([a-f0-9]+)/', $header, $vm);
$timestamp = $tm[1] ?? '';
$provided  = $vm[1] ?? '';
$expected  = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);

if (!hash_equals($expected, $provided) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit('Invalid signature');
}


$event = json_decode($raw, true);

// Route on the event name, not on status.
switch ($event['event']) {
    case 'payment.confirmed':
        // $event['reference'], $event['customerReference'], $event['amount']
        break;
    case 'payment.failed':
        // $event['failureReason']
        break;
    case 'payout.confirmed':
    case 'payout.failed':
        break;
}

http_response_code(200);
echo 'ok';