Webhook API
Real-time events, signed and retried.
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.
{
"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.
{
"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.
{
"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.
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
// 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';