Guides

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 https and be reachable from the internet. localhost and 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 as DERIPAY_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

http
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
json
{
  "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:

  1. Read the raw request body as bytes. Do not parse and re-serialise the JSON first.
  2. Split the header on , and read t and v1.
  3. Reject the request if t is more than 5 minutes from your clock.
  4. Compute HMAC-SHA256(secret, t + "." + raw_body) as lowercase hex.
  5. Compare it with v1 in constant time.

Reply 401 if verification fails.

Node

ts
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
<?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

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 2xx within 10 seconds. Do slow work after you reply, for example in a queue.
  • Deduplicate by event id. Store Deripay-Event-Id and 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 fetch GET /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.id and data.connection_id in 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 ping event 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.