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 at the beneficiary bank, a collection landing in your virtual account, a refund being rejected by the payer's bank.
Verifying the Signature
Verify every delivery before you act on it. An unverified endpoint will accept anything anyone posts to it.
Two rules catch almost every integration bug:
Sign the raw bytes. Compute the HMAC over the body exactly as received, before any JSON parsing. Parsing and re-serialising changes whitespace and key order, and the signature will not match.
Use the secret exactly as shown in the console, as a UTF-8 string. Do not base64-decode it.
Compare signatures in constant time. An ordinary string comparison returns early on the first mismatched byte, and the timing difference leaks the signature.
Delivery Headers
| Header | Value | Meaning |
|---|---|---|
| Content-Type | application/json | Always JSON. |
| X-CSL-Signature | base64 digest | HMAC-SHA512 of the raw request body, keyed with your signing secret, base64-encoded. Verify it before doing anything else. |
| X-CSL-Key-Id | Version of your signing key | Which version of your signing secret produced the signature. During a secret refresh two versions are valid at once; use this to pick the right one. |
Verifying the Signature
Verify every delivery before you act on it. An unverified endpoint will accept anything anyone posts to it.
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
@RestController
public class CslWebhookController {
@Value("${csl.webhook.secret}")
private String signingSecret;
private final WebhookQueue queue;
public CslWebhookController(WebhookQueue queue) {
this.queue = queue;
}
@PostMapping("/webhooks/csl")
public ResponseEntity<Void> receive(@RequestBody byte[] rawBody,
@RequestHeader("X-CSL-Signature") String signature) {
if (!verify(rawBody, signature)) {
return ResponseEntity.status(401).build();
}
// Acknowledge first, process afterwards. Deliveries time out after
// 10 seconds and are retried, so slow handlers cause duplicates.
queue.submit(rawBody);
return ResponseEntity.ok().build();
}
private boolean verify(byte[] rawBody, String provided) {
try {
Mac mac = Mac.getInstance("HmacSHA512");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA512"));
String expected = Base64.getEncoder().encodeToString(mac.doFinal(rawBody));
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 base64
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.
Refreshing Your Signing Secret
You can refresh your secret at any time. When you do, we start signing with the new secret immediately and keep accepting the previous one for 24 hours, so nothing breaks while you deploy.
During that window, read X-CSL-Key-Id on each delivery and verify against the secret it names.
If a secret has leaked, choose revoke immediately instead. Every older version stops being valid at once, and deliveries will fail verification until you deploy the new secret.
The secret is shown once, when it is created. We cannot show it again - if you lose it, refresh it.
Events
remittance.payout.successful
The payout reached the beneficiary account and is final.
{
"eventId": "evt_1cabef8aca983bca8cab9825fc026a10",
"eventVersion": "1.0",
"partnerId": "PTR-00184",
"eventType": "remittance.payout.successful",
"occurredAt": "2026-09-12T09:14:00Z",
"data": {
"partnerReference": "KR-PAY-000918234",
"transactionReference": "790598202412054567654576987814",
"status": "PAID",
"amount": {
"currency": "NGN",
"value": "250000.00"
},
"creditor": {
"name": "AMAKA NWOSU",
"accountNumber": "2081234567",
"bankDetail": {
"name": "Zenith Bank PLC",
"bankCode": "057",
"swiftCode": null
}
},
"result": {
"responseCode": "00",
"message": "Payout processed successfully"
},
"completedAt": "2026-09-12T09:14:00Z"
}
}
remittance.payout.failed
The payout could not be completed. The funds have been returned to your balance.
{
"eventId": "evt_a4c81982a5343335852117658dd7f607",
"eventVersion": "1.0",
"partnerId": "PTR-00184",
"eventType": "remittance.payout.failed",
"occurredAt": "2026-09-12T09:16:21Z",
"data": {
"partnerReference": "KR-PAY-000918235",
"transactionReference": "790598202412054567654576987815",
"status": "FAILED",
"amount": {
"currency": "NGN",
"value": "250000.00"
},
"creditor": {
"name": "CHIDI OKAFOR",
"accountNumber": "2089876543",
"bankDetail": {
"name": "Zenith Bank PLC",
"bankCode": "057",
"swiftCode": null
}
},
"result": {
"responseCode": "40",
"message": "Invalid beneficiary account"
},
"failedAt": "2026-09-12T09:16:21Z"
}
}
collection.payment.successful
The collection was received and your virtual account credited successfully
{
"eventId": "evt_3f8a1c9e5b7d4e2a9c6f0b1d8e4a7c52",
"eventVersion": "1.0",
"partnerId": "PTR-00184",
"eventType": "collection.payment.successful",
"occurredAt": "2025-05-21T08:38:04Z",
"data": {
"partnerReference": "INV-2025-00419",
"transactionReference": "FT250521NIP0099213",
"status": "POSTED",
"amount": {
"currency": "NGN",
"gross": "5000.00",
"fee": "25.00",
"net": "4975.00"
},
"paymentRail": {
"name": "NIP"
},
"debtor": {
"name": "JOHN DOE",
"accountNumber": "0065432190",
"bankDetail": {
"name": "Access Bank PLC",
"bankCode": "044",
"swiftCode": null
}
},
"creditor": {
"name": "MERCHANT COMPANY NAME CO, LTD (CHINA)",
"accountNumber": "XT123456789",
"bankDetail": {
"name": "Sterling Bank",
"bankCode": "232",
"swiftCode": null
}
},
"merchantId": "MER0012",
"subMerchantId": "SUB0045",
"valueDate": "2025-05-21",
"settledAt": "2025-05-21T08:38:00Z",
"stan": "000170"
}
}
collection.refund.completed
A refunded of the collected amount was completed successfully
{
"eventId": "evt_3658689f347a3fe2acaeebfe8862ca2d",
"eventVersion": "1.0",
"partnerId": "PTR-00184",
"eventType": "collection.refund.completed",
"occurredAt": "2025-05-23T14:02:11Z",
"data": {
"systemReference": "CSL-RFD-20250523-0000031",
"transactionReference": "RFD-REQ-88231",
"externalReference": "FT250523NIP0104887",
"status": "COMPLETED",
"refundType": "FULL",
"reason": "Payer recall — funds returned at remitting bank request",
"amount": {
"currency": "NGN",
"gross": "5000.00",
"fee": "0.00",
"net": "5000.00"
},
"paymentRail": {
"name": "NIP"
},
"debtor": {
"name": "MERCHANT COMPANY NAME CO, LTD (CHINA)",
"accountNumber": "XT123456789",
"bankDetail": {
"name": "Sterling Bank",
"bankCode": "232",
"swiftCode": null
}
},
"creditor": {
"name": "JOHN DOE",
"accountNumber": "0065432190",
"bankDetail": {
"name": "Access Bank PLC",
"bankCode": "044",
"swiftCode": null
}
},
"originalCollection": {
"systemReference": "CSL-COL-20250521-0000188",
"transactionReference": "INV-2025-00419",
"externalReference": "FT250521NIP0099213",
"amount": {
"currency": "NGN",
"gross": "5000.00",
"fee": "25.00",
"net": "4975.00"
},
"settledAt": "2025-05-21T08:38:00Z"
},
"merchantId": "MER0012",
"subMerchantId": "SUB0045",
"completedAt": "2025-05-23T14:02:08Z",
"failureReason": null
}
}
collection.refund.failed
When a refund request has not been processed successfully
{
"eventId": "evt_506221330def3b14877de6ca06c00616",
"eventVersion": "1.0",
"partnerId": "PTR-00184",
"eventType": "collection.refund.failed",
"occurredAt": "2025-05-23T14:02:11Z",
"data": {
"systemReference": "CSL-RFD-20250523-0000031",
"transactionReference": "RFD-REQ-88231",
"externalReference": "FT250523NIP0104887",
"status": "FAILED",
"refundType": "FULL",
"reason": "Payer recall — funds returned at remitting bank request",
"amount": {
"currency": "NGN",
"gross": "5000.00",
"fee": "0.00",
"net": "5000.00"
},
"paymentRail": {
"name": "NIP"
},
"debtor": {
"name": "MERCHANT COMPANY NAME CO, LTD (CHINA)",
"accountNumber": "XT123456789",
"bankDetail": {
"name": "Sterling Bank",
"bankCode": "232",
"swiftCode": null
}
},
"creditor": {
"name": "JOHN DOE",
"accountNumber": "0065432190",
"bankDetail": {
"name": "Access Bank PLC",
"bankCode": "044",
"swiftCode": null
}
},
"originalCollection": {
"systemReference": "CSL-COL-20250521-0000188",
"transactionReference": "INV-2025-00419",
"externalReference": "FT250521NIP0099213",
"amount": {
"currency": "NGN",
"gross": "5000.00",
"fee": "25.00",
"net": "4975.00"
},
"settledAt": "2025-05-21T08:38:00Z"
},
"merchantId": "MER0012",
"subMerchantId": "SUB0045",
"failedAt": "2025-05-23T14:02:08Z",
"failureReason": {
"code": "BENEFICIARY_ACCOUNT_DORMANT",
"message": "The payer's account at Access Bank is dormant and cannot receive credits",
"railResponseCode": "06"
}
}
}
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.
Registering an endpoint, or rotating a signing secret? Email technology@cslcapitaluk.com.