Webhooks
Receive signed transaction events and verify them on your server.
Deripay sends an HTTP POST to your server whenever a transaction changes. Webhooks are the reliable way to learn that a deposit or withdrawal finished, even if the customer closed the page.
Set up an endpoint
In the dashboard, open Webhooks and add an endpoint.
- The URL must use
httpsand be reachable from the internet.localhostand private network addresses are refused. - You can have up to 10 endpoints.
- Choose the events to send. By default an endpoint gets all four.
- The signing secret (
whsec_…) is shown when you create the endpoint or rotate its secret. Store it asDERIPAY_WEBHOOK_SECRET.
Events
| Event | Sent when |
|---|---|
transaction.updated |
Any status change. |
transaction.action_required |
A deposit is waiting on the customer (OTP, redirect or USSD). |
transaction.completed |
A deposit or withdrawal finished successfully. |
transaction.failed |
A deposit or withdrawal failed for good. |
A change that reaches action_required, completed or failed sends two events: transaction.updated and the specific one. Each has its own event id. Subscribe to the ones you need.
Test events from the dashboard use the type ping.
Payload
POST /webhooks/deripay HTTP/1.1
Content-Type: application/json
User-Agent: Deripay-Webhooks/2
Deripay-Signature: t=1762160472,v1=5f2b6c0e3a…
Deripay-Event: transaction.completed
Deripay-Event-Id: evt_3c1f9a8b7e6d5c4b3a29{
"id": "evt_3c1f9a8b7e6d5c4b3a29",
"type": "transaction.completed",
"created_at": "2026-11-03T09:01:12.000Z",
"data": {
"id": "txn_6720f9e8d7c6b5a4f3e2",
"object": "transaction",
"type": "deposit",
"state": "completed",
"status": "DEPOSIT_COMPLETED",
"status_message": "Deposit completed",
"connection_id": "con_6720f1a2b3c4d5e6f7a8",
"country": "KE",
"currency": "KES",
"operator": "mpesa",
"amount": 1000,
"amount_usd": 7.52,
"phone_number": "2547•••678",
"customer_action": null,
"terminal": true,
"created_at": "2026-11-03T09:00:00.000Z",
"updated_at": "2026-11-03T09:01:12.000Z"
}
}| Field | Description |
|---|---|
id |
Event id, evt_…. Same as the Deripay-Event-Id header. |
type |
Event type. Same as the Deripay-Event header. |
created_at |
When the event was created. |
data |
For transaction.* events, the full transaction object. For ping, {"message": "..."}. |
Verify the signature
The Deripay-Signature header looks like t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of the string "<t>.<raw request body>", keyed with your endpoint secret.
To verify:
- Read the raw request body as bytes. Do not parse and re-serialise the JSON first.
- Split the header on
,and readtandv1. - Reject the request if
tis more than 5 minutes from your clock. - Compute
HMAC-SHA256(secret, t + "." + raw_body)as lowercase hex. - Compare it with
v1in constant time.
Reply 401 if verification fails.
Node
import crypto from "node:crypto";
export function verifyDeripaySignature(
rawBody: string,
header: string | null,
secret: string,
toleranceSeconds = 300,
): boolean {
if (!header) return false;
const parts: Record<string, string> = {};
for (const item of header.split(",")) {
const i = item.indexOf("=");
if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
}
const t = parts.t;
const v1 = parts.v1;
if (!t || !/^\d+$/.test(t) || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(v1, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}In a Next.js route handler, read the body with await request.text() and pass that string. In Express, use express.raw({ type: "application/json" }) and pass req.body.toString("utf8").
PHP
<?php
function deripay_verify(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $item) {
$kv = explode('=', trim($item), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $payload, $secret);
return hash_equals($expected, $parts['v1']);
}
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_DERIPAY_SIGNATURE'] ?? '';
if (!deripay_verify($payload, $header, getenv('DERIPAY_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($payload, true);
http_response_code(200);Python
import hashlib
import hmac
import time
def verify_deripay_signature(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = {}
for item in (header or "").split(","):
key, sep, value = item.strip().partition("=")
if sep:
parts[key] = value
t, v1 = parts.get("t"), parts.get("v1")
if not t or not t.isdigit() or not v1:
return False
if abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)With Flask, pass request.get_data() as payload and request.headers.get("Deripay-Signature", "") as header.
Handle events safely
- Answer fast. Reply with any
2xxwithin 10 seconds. Do slow work after you reply, for example in a queue. - Deduplicate by event id. Store
Deripay-Event-Idand skip events you have already processed. The same event can arrive more than once. - Do not rely on order. Events can arrive out of order. Use
data.updated_at, or fetchGET /transactions/{id}for the current state, before you change a balance in your own system. - Credit once. When you show "deposit complete" or update your own ledger, key it on the transaction id so a repeat cannot double it.
- Check the transaction is yours. Look up
data.idanddata.connection_idin your own records.
Retries and failures
- Any response outside
2xx, a redirect, a network error or no answer within 10 seconds counts as a failure. - A failed delivery is retried once straight away.
- After 25 consecutive failed deliveries the endpoint is disabled. Fix it, then enable it again in the dashboard. That resets the failure count.
Because retries are short, do not depend on webhooks alone. Your cashier screen should also poll or stream, and a nightly job can list non-final transactions with GET /transactions?state=processing and fetch each one.
Test and redeliver
From the Webhooks page in the dashboard you can:
- Send a test event: a signed
pingevent to check your URL and signature code. - See deliveries: each attempt with its status code, response body and time taken.
- Redeliver: send a past event again with the same event id and payload and a fresh signature.
- Rotate the secret: issue a new
whsec_…. The old secret stops working at once, so update your server straight after.