Webhooks
We send an HTTP POST to your endpoint when something happens on your account. Webhooks are how you learn about events you did not initiate — a payout settling hours later, a mandate being revoked by the debtor, a collection failing at the bank.
Delivery headers
| Header | Value | Meaning |
|---|---|---|
| Content-Type | application/json | Always JSON. |
| X-CSL-Signature | base64 digest | HMAC-SHA512 of the raw body, keyed with your signing secret. |
Verifying the signature
Verify every delivery before you act on it. An unverified endpoint will accept anything anyone posts to it.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
public class WebhookController {
private static final long TOLERANCE_SECONDS = 300;
@Value("${csl.webhook.secret}")
private String signingSecret;
@PostMapping("/webhooks/csl")
public ResponseEntity<Void> receive(
@RequestBody byte[] rawBody,
@RequestHeader("X-CSL-Signature") String signature) {
byte[] signedPayload = rawBody;
if (!verify(signedPayload, signature)) {
return ResponseEntity.status(401).build();
}
// Acknowledge first, then process asynchronously. Deliveries time out
// after 10 seconds and are retried, so slow handlers cause duplicates.
events.submit(rawBody);
return ResponseEntity.ok().build();
}
private boolean verify(byte[] signedPayload, String provided) {
try {
Mac mac = Mac.getInstance("HmacSHA512");
mac.init(new SecretKeySpec(
signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA512"));
String expected = java.util.Base64.getEncoder().encodeToString(mac.doFinal(signedPayload));
// MessageDigest.isEqual is constant time. String.equals is not, and
// returning early on the first mismatched byte leaks the signature.
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
provided.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
return false;
}
}
}const crypto = require("crypto");
const express = require("express");
const app = express();
const TOLERANCE_SECONDS = 300;
// express.raw, not express.json — the signature covers the bytes as sent.
app.post("/webhooks/csl", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("X-CSL-Signature");
const signedPayload = req.body;
const expected = crypto
.createHmac("sha512", process.env.CSL_WEBHOOK_SECRET)
.update(signedPayload)
.digest("base64");
// timingSafeEqual throws on a length mismatch, so check that first.
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
// Acknowledge immediately, process on a queue.
res.sendStatus(200);
enqueue(JSON.parse(req.body.toString("utf8")));
});import hashlib
import hmac
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
TOLERANCE_SECONDS = 300
SECRET = os.environ["CSL_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/csl")
def receive():
# request.get_data() returns the raw bytes; request.json would not.
raw_body = request.get_data()
signature = request.headers.get("X-CSL-Signature", "")
signed_payload = raw_body
digest = hmac.new(SECRET, signed_payload, hashlib.sha512)
expected = base64.b64encode(digest.digest()).decode()
# compare_digest is constant time; == is not.
if not hmac.compare_digest(expected, signature):
abort(401)
enqueue(request.get_json())
return "", 200<?php
$tolerance = 300;
$secret = getenv('CSL_WEBHOOK_SECRET');
// php://input is the raw body. $_POST would already be parsed.
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_CSL_SIGNATURE'] ?? '';
$signedPayload = $rawBody;
$expected = hash_hmac('sha512', $signedPayload, $secret, true);
$expected = base64_encode($expected);
// hash_equals is constant time; === is not.
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
http_response_code(200);
fastcgi_finish_request(); // acknowledge, then process
enqueue(json_decode($rawBody, true));Your signing secret is issued per environment in the console. A sandbox secret will never verify a production delivery.
Events
payout.settled
The payout reached the beneficiary account and is final.
{
"id": "evt_3f1a9c408b6e4a2f",
"type": "payout.settled",
"createdAt": "2026-01-15T09:30:00Z",
"data": {
"payoutId": "3f1a9c40-8b6e-4a2f-9c11-5d7e2b8a6f00",
"reference": "PO-2026-0001",
"status": "SETTLED",
"amount": {
"currency": "NGN",
"value": 51636000
}
}
}
payout.failed
The payout could not be completed. The funds have been returned to your balance.
{
"id": "evt_9c11ab7720e14d02",
"type": "payout.failed",
"createdAt": "2026-01-15T09:31:12Z",
"data": {
"payoutId": "3f1a9c40-8b6e-4a2f-9c11-5d7e2b8a6f00",
"reference": "PO-2026-0001",
"status": "FAILED",
"failureReason": "BENEFICIARY_ACCOUNT_CLOSED"
}
}
Delivery and retries
A delivery is successful when your endpoint returns 2xx within 10 seconds. Anything else is retried with exponential backoff for 24 hours. Respond first, process afterwards — do not hold the connection open while you write to your database.
Deliveries are not ordered. A settled event can arrive before the created event for the same transaction, and any event can be delivered more than once. Key your handler on the event id and make it idempotent.