Skip to content

Webhooks

Regini sends signed POST requests to your registered callback URL when key events occur on an account.


Registering your endpoint

Use the Regini partner portal to register your webhook URL and select which events to subscribe to.

You will receive a signing_secret once at registration. Store it securely; it is not shown again.


Supported events

Event When it fires
deposit.completed User confirmed the M-Pesa STK push and funds are credited
deposit.failed Ledger credit failed after M-Pesa payment was collected (rare)
withdrawal.completed KES payout delivered to user's M-Pesa
withdrawal.failed Kotani payout failed or was cancelled; USDC is restored to the account

Payloads

All events include account_id, transaction_id, and external_user_id. The reference field is only present if you supplied one when initiating the transaction.

deposit.completed

{
  "event": "deposit.completed",
  "account_id": "3f8a1b2c-...",
  "transaction_id": "tx-uuid-...",
  "external_user_id": "your-internal-user-id",
  "amount_kes": 5000.0,
  "amount_usdc": 38.07,
  "exchange_rate": 131.35,
  "reference": "your-ref"
}

deposit.failed

{
  "event": "deposit.failed",
  "account_id": "3f8a1b2c-...",
  "transaction_id": "tx-uuid-...",
  "external_user_id": "your-internal-user-id",
  "amount_kes": 5000.0,
  "amount_usdc": 38.07,
  "exchange_rate": 131.35,
  "reason": "ledger_credit_failed",
  "reference": "your-ref"
}

Warning

A deposit.failed event means M-Pesa collected the KES but the USDC credit could not be recorded. This triggers an immediate alert to Regini operations for manual reconciliation. The user's money is not lost.

withdrawal.completed

{
  "event": "withdrawal.completed",
  "account_id": "3f8a1b2c-...",
  "transaction_id": "tx-uuid-...",
  "external_user_id": "your-internal-user-id",
  "amount_kes": 4970.0,
  "amount_usdc": 38.0,
  "exchange_rate": 130.79,
  "reference": "your-ref"
}

withdrawal.failed

{
  "event": "withdrawal.failed",
  "account_id": "3f8a1b2c-...",
  "transaction_id": "tx-uuid-...",
  "external_user_id": "your-internal-user-id",
  "amount_kes": 4970.0,
  "amount_usdc": 38.0,
  "reason": "payout_failed",
  "reference": "your-ref"
}

When withdrawal.failed fires, the USDC has been restored to the user's account and the transaction is marked failed. No KES was delivered. The user can retry.


Verifying the signature

Every webhook request includes two headers:

  • X-Regini-Signature: sha256=<hex>
  • X-Regini-Event: <event_name>

Always verify the signature before trusting the payload.

How it works

Regini computes an HMAC-SHA256 over the raw request body using your signing_secret, then prefixes the hex digest with sha256=. That full string, including the sha256= prefix, is what appears in the X-Regini-Signature header. To verify, you compute the same HMAC on your side and compare the two strings.

The body is the exact UTF-8 JSON string sent in the HTTP request, no reformatting, no key reordering. You must sign the raw bytes your HTTP framework gives you before parsing them as JSON. Re-serializing the parsed object will likely produce different bytes (different whitespace or key order) and the comparison will fail.

The signature header always starts with sha256=. For example:

X-Regini-Signature: sha256=3b5a2c1f8e4d7b9a0f2e6c3d1a8b5f4e2c9d7a0b3f1e8c5d2a9b6f3e0c7d4a1

When comparing, include the sha256= prefix on both sides, compare the full header value against your computed sha256=<hex> string.

import hmac
import hashlib


def verify_webhook(secret: str, body: bytes, signature_header: str) -> bool:
    """
    secret:           your signing_secret from the partner portal
    body:             raw request body bytes, before any JSON parsing
    signature_header: full value of X-Regini-Signature, e.g. "sha256=3b5a2c..."
    """
    expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)

Example with Flask:

@app.route("/webhook", methods=["POST"])
def webhook():
    body = request.get_data()  # raw bytes — do NOT call request.json first
    sig = request.headers.get("X-Regini-Signature", "")
    if not verify_webhook(SIGNING_SECRET, body, sig):
        return "Unauthorized", 401
    event = request.get_json()
    # handle event...
    return "OK", 200
const crypto = require("crypto");

function verifyWebhook(secret, body, signatureHeader) {
  // secret:          your signing_secret from the partner portal
  // body:            raw request body as a Buffer or string
  // signatureHeader: full value of X-Regini-Signature, e.g. "sha256=3b5a2c..."
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

Example with Express (requires express.raw middleware to access the raw body):

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-regini-signature"] ?? "";
  if (!verifyWebhook(SIGNING_SECRET, req.body, sig)) {
    return res.status(401).send("Unauthorized");
  }
  const event = JSON.parse(req.body);
  // handle event...
  res.sendStatus(200);
});

Responding

Return any 2xx status to acknowledge receipt. If your endpoint returns a non-2xx or times out (10 seconds), Regini retries with the following schedule:

Attempt Delay after previous failure
2 10 seconds
3 30 seconds
4 2 minutes
5 10 minutes
6 60 minutes

After 6 failed attempts (1 initial + 5 retries) the delivery is marked as failed. No further retries are made.

Info

Webhook delivery failures do not affect the underlying transaction. Funds are not reversed if a webhook cannot be delivered.