Webhooks
Stop polling us. Register one HTTPS URL, tell us which events it should receive, and we post to it within seconds of the money moving — signed, so you can prove the message came from us, and retried if your service is down.
Setting this up is two steps:
- Create a signing secret.
- Register your URL and its events. One call. If your handler is already running, that call comes back
ACTIVEand you are live.
After that your endpoint receives notifications and answers 200. There is nothing to poll and nothing to acknowledge separately.
Before You Begin
| What you need | Where it comes from |
|---|---|
| A signing secret | POST /api/v1/partners/{partnerId}/webhooks/keys, or the Signing key screen in your console |
| An HTTPS callback URL | Yours. Public internet, port 443, 80 or 8443. localhost and private addresses are rejected |
| A handler at that URL | Have it deployed before you register and you will be live in one call |
| Your partner id | Shown in your console. Sent as X-Partner-Id on every API call |
Create the secret first. If you register a URL before you have one, we provision a secret automatically so your endpoint is never unsigned — but that secret is never displayed, and you would have to rotate it to get a usable one. The order is secret, then URL.
curl -X POST \
https://api.cslpay.io/webhook-notification/api/v1/partners/PTR-00184/webhooks/keys \
-H 'X-Partner-Id: PTR-00184' \
-H 'Content-Type: application/json'{
"keyId": "v1",
"version": 1,
"algorithm": "HMAC-SHA512",
"secret": "Qm8xT3nVb2Lk9Rz4WcY7pHa0dEs6FgJu5Ni1oXqMtKe",
"createdAt": "2026-10-17T09:02:11.418Z",
"warning": "Store this secret now. It cannot be retrieved again."
}The secret is shown exactly once. Store it in your vault before you close the response.
Sandbox secrets do not verify production deliveries, and the reverse. Each environment has its own.
Step 1 — Register Your URL
One call. Give us the URL and the events it should receive.
curl -X POST \
https://api.cslpay.io/webhook-notification/api/v1/partners/PTR-00184/webhooks \
-H 'X-Partner-Id: PTR-00184' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://api.yourdomain.com/webhooks/payments",
"events": ["remittance.payout.successful", "collection.payment.successful"]
}'{
"id": "7f1c0b4e-2d93-4a17-b5e8-c0a9d6f3e188",
"partnerId": "PTR-00184",
"url": "https://api.yourdomain.com/webhooks/payments",
"status": "ACTIVE",
"events": ["collection.payment.successful", "remittance.payout.successful"],
"maxAttempts": 8,
"rateLimitPerSecond": 50,
"consecutiveFailures": 0,
"verifiedAt": "2026-10-17T09:04:23.102Z",
"createdAt": "2026-10-17T09:04:22.517Z",
"updatedAt": "2026-10-17T09:04:22.517Z",
"activation": { "verified": true, "responseStatus": 200 }
}status: ACTIVE means you are done — events are flowing. Read on only if it says PENDING_VERIFICATION.
Want everything? Omit events, send it empty, or send ["*"]. You are then subscribed to every event type in the catalogue, including ones we add later.
One URL, many events. The endpoint owns the URL and everything true of it: one verification, one failure count, one suspension, and one rate limit — rateLimitPerSecond means deliveries per second at your service, however many event types point at it. If you would rather split payouts and collections across two services, register a second URL with its own event list.
Re-registering the same URL is safe. It updates that endpoint's event list and settings rather than failing, so you can run this from a deployment script. The response's events array is always the complete current list.
URL rules. https only; port 443, 80 or 8443; a public hostname resolving to a public address; no credentials in the URL. These are checked here and again on every change, so localhost and tunnels to private addresses will not pass — use a publicly reachable staging host.
If it comes back PENDING_VERIFICATION
Registration proves you own the URL before any real event is sent: we post a challenge to it and expect it echoed back. This runs inside the call above, so a handler that is already deployed never notices. If yours was not ready, you get this instead:
{
"status": "PENDING_VERIFICATION",
"activation": {
"verified": false,
"responseStatus": 404,
"error": "Your endpoint returned HTTP 404 — it must return 200",
"nextStep": "Fix the handler, then POST /api/v1/partners/PTR-00184/webhooks/7f1c0b4e-2d93-4a17-b5e8-c0a9d6f3e188/verify to try again. Events are held meanwhile and cancelled after 72 hours."
}
}The challenge we post carries the header X-CSL-Event: webhook.verify and a challenge field:
{
"eventId": "evt_7d0e5b1c9a2f3e84b6c1d0a9f5e2b378",
"eventVersion": "1.0",
"partnerId": "PTR-00184",
"eventType": "webhook.verify",
"occurredAt": "2026-10-17T09:05:00Z",
"challenge": "dK8vN2pQwL5xR7tY"
}Return 200 with this body, exactly:
{ "challenge": "dK8vN2pQwL5xR7tY" }Four lines, in its own class — the header separates it from your event handler:
@RestController
public class WebhookActivationController {
@PostMapping(value = "/webhooks/payments", headers = "X-CSL-Event=webhook.verify")
public Map<String, String> activate(@RequestBody Map<String, Object> challenge) {
return Map.of("challenge", String.valueOf(challenge.get("challenge")));
}
}const express = require("express");
const app = express();
// express.raw, not express.json — the same route also receives signed events,
// and those have to be verified against the bytes exactly as sent.
app.post("/webhooks/payments", express.raw({ type: "application/json" }), (req, res) => {
const body = JSON.parse(req.body.toString("utf8"));
if (req.header("X-CSL-Event") === "webhook.verify") {
return res.json({ challenge: body.challenge });
}
// ... your event handling ...
});from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/webhooks/payments")
def receive():
if request.headers.get("X-CSL-Event") == "webhook.verify":
return jsonify(challenge=request.get_json()["challenge"]), 200
# ... your event handling ...<?php
// php://input is the raw body. $_POST would already be parsed, and the same
// route receives signed events that must be verified byte for byte.
$rawBody = file_get_contents('php://input');
if (($_SERVER['HTTP_X_CSL_EVENT'] ?? '') === 'webhook.verify') {
$body = json_decode($rawBody, true);
header('Content-Type: application/json');
echo json_encode(['challenge' => $body['challenge']]);
exit;
}
// ... your event handling ...Then retry with the nextStep path. What usually goes wrong:
activation.error says | Cause |
|---|---|
| returned 200 with an empty body | The handler returned without the challenge |
no "challenge" field | Returning {"status":"ok"} or similar. The field must be named challenge |
| not JSON | An HTML error page. The body is parsed and compared exactly |
| could not reach your endpoint | Not reachable from the public internet, or TLS failed |
Changing the URL later re-runs this automatically — proving you own the old address says nothing about the new one. Changing the event list, headers or limits does not.
Step 2 — Verify Every Delivery
Verify before you trust anything in the body.
X-CSL-Signature = base64( HMAC-SHA512( key = UTF-8 bytes of your secret,
message = the raw request body ) )Three rules, all load-bearing:
- Hash the raw bytes, before any JSON parsing. A parsed and re-serialised body has different bytes and will never match.
- Use the secret as a string. Do not base64-decode it.
- Pick the secret named by
X-CSL-Key-Id. During a refresh two secrets are valid at once.
@Component
@ConfigurationProperties(prefix = "webhook")
public class WebhookSignature {
private Map<String, String> secrets = new LinkedHashMap<>();
public Map<String, String> getSecrets() {
return secrets;
}
public void setSecrets(Map<String, String> secrets) {
this.secrets = secrets;
}
public boolean matches(byte[] rawBody, String signature, String keyId) throws Exception {
String secret = secrets.get(keyId);
if (secret == null) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA512");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA512"));
String expected = Base64.getEncoder().encodeToString(mac.doFinal(rawBody));
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
}
}const crypto = require("crypto");
// One entry per secret that is currently valid. During a refresh there are two,
// and X-CSL-Key-Id says which one signed the delivery in your hands.
const SECRETS = {
v1: process.env.CSL_WEBHOOK_SECRET,
};
function signatureMatches(rawBody, signature, keyId) {
const secret = SECRETS[keyId];
if (!secret) return false;
const signedPayload = rawBody;
const expected = crypto
.createHmac("sha512", secret)
.update(signedPayload)
.digest("base64");
// timingSafeEqual throws on a length mismatch, so compare lengths first.
// Comparing with === returns on the first wrong byte and leaks the signature.
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
module.exports = { signatureMatches };import base64
import hashlib
import hmac
import os
# One entry per secret that is currently valid. During a refresh there are two,
# and X-CSL-Key-Id says which one signed the delivery in your hands.
SECRETS = {
"v1": os.environ["CSL_WEBHOOK_SECRET"],
}
def signature_matches(raw_body, signature, key_id):
secret = SECRETS.get(key_id)
if secret is None:
return False
signed_payload = raw_body
digest = hmac.new(secret.encode(), signed_payload, hashlib.sha512)
expected = base64.b64encode(digest.digest()).decode()
# compare_digest is constant time; == returns on the first wrong byte.
return hmac.compare_digest(expected, signature or "")<?php
// One entry per secret that is currently valid. During a refresh there are two,
// and X-CSL-Key-Id says which one signed the delivery in your hands.
function webhook_secrets(): array
{
return ['v1' => getenv('CSL_WEBHOOK_SECRET')];
}
function signature_matches(string $rawBody, string $signature, string $keyId): bool
{
$secret = webhook_secrets()[$keyId] ?? null;
if ($secret === null) {
return false;
}
$signedPayload = $rawBody;
$expected = hash_hmac('sha512', $signedPayload, $secret, true);
$expected = base64_encode($expected);
// hash_equals is constant time; === returns on the first wrong byte.
return hash_equals($expected, $signature);
}webhook:
secrets:
v1: "Qm8xT3nVb2Lk9Rz4WcY7pHa0dEs6FgJu5Ni1oXqMtKe"One entry per secret that is currently valid. Compare in constant time, as above — equals() on the two strings leaks timing information.
Step 3 — Receive the Notification
Verify, read the payload, answer 200.
@RestController
public class WebhookController {
private final WebhookSignature signature;
private final ObjectMapper json = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
public WebhookController(WebhookSignature signature) {
this.signature = signature;
}
@PostMapping("/webhooks/payments")
public ResponseEntity<Void> receive(
@RequestBody byte[] rawBody,
@RequestHeader("X-CSL-Signature") String signatureHeader,
@RequestHeader("X-CSL-Key-Id") String keyId) throws Exception {
if (!signature.matches(rawBody, signatureHeader, keyId)) {
return ResponseEntity.status(401).build();
}
WebhookPayloads.Envelope event = json.readValue(rawBody, WebhookPayloads.Envelope.class);
WebhookPayloads.WebhookPayload payload = WebhookPayloads.parse(event, json);
// Hand `payload` to your own service here.
return ResponseEntity.ok().build();
}
}const express = require("express");
const { signatureMatches } = require("./webhook-signature");
const app = express();
// express.raw, not express.json. The signature covers the bytes as sent, and a
// parsed-and-reserialised body has different ones.
app.post("/webhooks/payments", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("X-CSL-Signature");
const keyId = req.header("X-CSL-Key-Id");
if (!signatureMatches(req.body, signature, keyId)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// Acknowledge first, process afterwards. A handler that runs past the
// 10-second budget is timed out and the event is sent again, so slow work
// becomes duplicated work.
res.sendStatus(200);
enqueue(event);
});from flask import Flask, request, abort
from webhook_signature import signature_matches
app = Flask(__name__)
@app.post("/webhooks/payments")
def receive():
# get_data() returns the raw bytes; request.json would parse them first.
raw_body = request.get_data()
signature = request.headers.get("X-CSL-Signature", "")
key_id = request.headers.get("X-CSL-Key-Id", "")
if not signature_matches(raw_body, signature, key_id):
abort(401)
event = request.get_json()
# Acknowledge first, process afterwards. A handler that runs past the
# 10-second budget is timed out and the event is sent again.
enqueue(event)
return "", 200<?php
require __DIR__ . '/webhook-signature.php';
// php://input is the raw body. $_POST would already be parsed.
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_CSL_SIGNATURE'] ?? '';
$keyId = $_SERVER['HTTP_X_CSL_KEY_ID'] ?? '';
if (!signature_matches($rawBody, $signature, $keyId)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// Acknowledge first, process afterwards. A handler that runs past the
// 10-second budget is timed out and the event is sent again.
http_response_code(200);
fastcgi_finish_request();
enqueue($event);Note @RequestBody byte[]. Binding to a DTO would let the framework parse the body before you verify it.
Delivery Headers
| Header | Value | Use it for |
|---|---|---|
Content-Type | application/json | Always JSON |
X-CSL-Signature | base64 digest | Required. HMAC-SHA512 of the raw body |
X-CSL-Key-Id | e.g. v1 | Required. Which secret signed this delivery |
X-CSL-Event-Id | e.g. evt_518b… | De-duplication. Identical on every retry and replay |
X-CSL-Event | e.g. remittance.payout.successful | Routing before parsing |
X-CSL-Event-Version | 1.0 | Payload schema version |
X-CSL-Delivery-Id | a UUID | This attempt. Quote it in a support ticket |
X-CSL-Attempt | 1 on the first try | Logging. Never logic |
Any custom headers you registered on the endpoint are passed through unchanged. They sit beside the signed body, not inside it, so a custom header is routing, not proof — the signature is the proof.
The Event Envelope
Every delivery, activation included, arrives in the same envelope. data holds the payload for that event type.
{
"eventId": "evt_518b5615f25a3b13aaacd851e047ef20",
"eventVersion": "1.0",
"partnerId": "PTR-00184",
"eventType": "collection.payment.successful",
"occurredAt": "2026-10-17T09:14:01Z",
"data": { }
}| Field | Meaning |
|---|---|
eventId | Identifies the business event. Identical on every retry and every replay — de-duplicate on it |
eventVersion | Payload schema version |
partnerId | Your partner id |
eventType | Which of the five events this is |
occurredAt | When the money moved — not when the message was sent |
data | The payload, per the tables below |
A field with no value is present as null rather than omitted, so you can parse one model for both the success and failure shapes of an event.
Events and Payloads
| Event | Fires when |
|---|---|
remittance.payout.successful | The payout reached the beneficiary and is final |
remittance.payout.failed | The payout could not be completed |
collection.payment.successful | A payment was received and your virtual account credited |
collection.refund.completed | A refund reached the payer |
collection.refund.failed | A refund could not be paid out |
Two differences worth knowing before you write a handler
The amount is shaped differently per family.
| Remittance | Collection | |
|---|---|---|
amount | { "currency", "value" } | { "currency", "gross", "fee", "net" } |
The "did the money come back" flag is spelled differently.
| Event | Field |
|---|---|
remittance.payout.failed | balanceRestored (singular) |
collection.refund.failed | balancesRestored (plural) |
A handler written for one family and reused for the other reads null and false respectively — silently reporting that money was not returned when it was.
remittance.payout.successful · remittance.payout.failed
| Field | Type | Notes |
|---|---|---|
partnerReference | string | Your own reference |
transactionReference | string | The payout's reference |
status | string | |
amount | object | currency, value |
creditor | object | name, accountNumber, bankDetail{name, bankCode, swiftCode} |
result | object | responseCode — stable across processors, branch on this — and message |
balanceRestored | boolean | failed only. true = your wallet debit has been reversed |
completedAt / failedAt | timestamp |
{
"partnerReference": "KR-PAY-000918234",
"transactionReference": "CSL-RMT-2026-0918234",
"status": "PAID",
"amount": { "currency": "NGN", "value": "250000.00" },
"creditor": {
"name": "Ada Obi",
"accountNumber": "0123456789",
"bankDetail": { "name": "Zenith Bank", "bankCode": "057", "swiftCode": null }
},
"result": { "responseCode": "00", "message": "Approved" },
"completedAt": "2026-10-17T09:14:00Z"
}balanceRestored answers the one question you cannot answer yourself: whether the debit against your wallet has been reversed. Most failures reverse; some response codes do not. It reports what the ledger actually did, so treat false as "the funds are still out".
collection.payment.successful
| Field | Type | Notes |
|---|---|---|
partnerReference | string | Your own reference |
transactionReference | string | The bank's reference, not ours |
status | string | |
amount | object | currency, gross, fee, net |
paymentRail | object | name |
debtor / creditor | object | The payer, and the virtual account credited |
merchantId, subMerchantId | string | |
valueDate | date | |
settledAt | timestamp | |
stan | string | Rail trace number. Frequently null |
{
"partnerReference": "INV-2025-00419",
"transactionReference": "ZEN-CR-88231004",
"status": "POSTED",
"amount": { "currency": "NGN", "gross": "250000.00", "fee": "500.00", "net": "249500.00" },
"paymentRail": { "name": "NIP" },
"valueDate": "2026-10-17",
"settledAt": "2026-10-17T09:19:58Z",
"stan": null
}Credit the merchant with net. fee is what we took.
collection.refund.completed · collection.refund.failed
| Field | Type | Notes |
|---|---|---|
partnerReference | string | |
transactionReference | string | |
status, refundType, reason | string | |
amount | object | currency, gross, fee, net |
paymentRail, debtor, creditor | object | |
originalCollection | object | partnerReference, transactionReference, amount, settledAt — matches the refund to the original payment without a second lookup |
merchantId, subMerchantId | string | |
balancesRestored | boolean | failed only. true = the merchant was debited and has been credited back; false = no money moved |
completedAt / failedAt | timestamp | |
failureReason | object | code (stable), message, railResponseCode. Always present, always null on a completed refund |
Responding to a Delivery
| You return | We do |
|---|---|
Any 2xx | Delivered. No further attempts |
408, 429, any 5xx, or no answer | Retry on the ladder below |
429 with Retry-After: 120 | Wait exactly 120 seconds. Honoured up to 24 hours |
410 Gone | Stop permanently. That delivery is never retried |
Any 3xx | Treated as rejected — we do not follow redirects |
Any other 4xx | Rejected, and capped at 3 attempts rather than the full ladder |
Two consequences worth planning for:
- A
401from a broken signature check stops after three attempts, not eight. A signature check does not start working because we tried again. Fix it, then replay from your console. - Moving your endpoint with a
301will not work. Change the URL through the API.
The response body is ignored except on activation. We store the first 4 KB of whatever you return, in delivery history, for three months, visible to both sides — so an error page that echoes the request body is storing that on our side. An empty 200 is the clean answer.
Delivery, Retries and Duplicates
A delivery succeeds on any 2xx within 10 seconds. Otherwise it is retried up to 8 attempts over about 9 hours, with these gaps:
10s → 30s → 2m → 10m → 30m → 2h → 6hrateLimitPerSecond is a ceiling on deliveries to your URL, not per event type.
After 20 consecutive failures we pause the endpoint and hold its events rather than keep hammering a service that is already struggling. The pause clears itself — the first is 15 minutes, lengthening as failures continue, capped at 6 hours. Nothing is lost; held events go out when sending resumes.
The same event can arrive more than once. That is the retry guarantee, not a fault: if your 200 is lost on the way back to us, we try again. The eventId is identical every time — it is derived from the event type and the underlying transaction, not generated per attempt — so de-duplicating on it is sufficient. It is identical on a replay months later too.
The robust implementation is a unique constraint:
INSERT INTO webhook_event (event_id, payload) VALUES (?, ?)
ON CONFLICT (event_id) DO NOTHINGA single atomic statement, not a SELECT followed by an INSERT — two concurrent redeliveries both pass the latter.
Order is not guaranteed. Events are dispatched in parallel, and a retried event lands after newer ones. Sequence on occurredAt, never on arrival time.
Refreshing Your Signing Secret
A refresh mints a new secret, signs with it immediately, and keeps the previous one valid for 24 hours so you can deploy at your own pace.
curl -X POST \
https://api.cslpay.io/webhook-notification/api/v1/partners/PTR-00184/webhooks/keys/rotate \
-H 'X-Partner-Id: PTR-00184'{
"keyId": "v2",
"version": 2,
"algorithm": "HMAC-SHA512",
"secret": "R7tYmK2xQ9wLnZ6bY3cH5jD0sF8gP4vT1aB8eU3dN6o",
"createdAt": "2026-10-17T09:14:02.118Z",
"previousKeyValidUntil": "2026-10-18T09:14:02.118Z",
"warning": "Store this secret now. It cannot be retrieved again."
}- Call rotate and capture the new secret.
- Add it to your configuration alongside the old one.
- Deploy. Both now verify, and
X-CSL-Key-Idsays which signed each delivery. - Remove the old one any time after
previousKeyValidUntil.
Nothing breaks if step 4 is late — the old version simply stops being offered.
If a secret leaks, add ?immediate=true. Every older version is revoked at once with no overlap, so callbacks fail until you deploy the new secret. That is the correct trade for a compromised credential.
Testing Your Integration
Use Send test event in your console, or:
curl -X POST \
'https://api.cslpay.io/webhook-notification/api/v1/partners/PTR-00184/webhooks/events/remittance.payout.successful/test' \
-H 'X-Partner-Id: PTR-00184'It sends a correctly signed sample of the real payload through the production signing path, so a handler that passes a test has a verification routine that works. The envelope carries "test": true and the delivery is not recorded in your history.
{ "success": true, "responseStatus": 200, "durationMs": 214, "responseBody": "" }This route answers 200 whether the test passed or failed — the status reports that we ran the test, not what your endpoint said. Read success.
Without Spring Boot
The contract is plain HTTP, so no framework is required. The same endpoint on the JDK's built-in server:
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/webhooks/payments", exchange -> {
if ("webhook.verify".equals(exchange.getRequestHeaders().getFirst("X-CSL-Event"))) {
activation.handle(exchange); // echo the challenge
} else {
events.handle(exchange); // verify, read, 200
}
});
// Without an executor the server handles one request at a time, which will cost you the
// 10-second budget as soon as two events arrive together.
server.setExecutor(Executors.newFixedThreadPool(16));
server.start();const http = require("http");
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/webhooks/payments") {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on("data", c => chunks.push(c));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
if (req.headers["x-csl-event"] === "webhook.verify") {
activate(rawBody, res); // echo the challenge
} else {
handleEvent(rawBody, req, res); // verify, read, 200
}
});
});
// Node handles requests concurrently on one thread, so there is no pool to
// size — but anything blocking in your handler costs the 10-second budget for
// every delivery behind it. Keep the handler to a queue write.
server.listen(8080);from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/webhooks/payments":
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
raw_body = self.rfile.read(length)
if self.headers.get("X-CSL-Event") == "webhook.verify":
activate(raw_body, self) # echo the challenge
else:
handle_event(raw_body, self) # verify, read, 200
# ThreadingHTTPServer, not HTTPServer: the single-threaded version handles one
# request at a time, which costs you the 10-second budget as soon as two
# events arrive together.
ThreadingHTTPServer(("", 8080), WebhookHandler).serve_forever()<?php
// PHP-FPM already runs each request in its own worker, so there is no server
// loop to write and no thread pool to size. Point your web server at this file
// for /webhooks/payments and keep the handler to a queue write — anything slower costs
// the 10-second budget.
$rawBody = file_get_contents('php://input');
if (($_SERVER['HTTP_X_CSL_EVENT'] ?? '') === 'webhook.verify') {
activate($rawBody); // echo the challenge
} else {
handle_event($rawBody); // verify, read, 200
}Reading and verifying, with no framework in the way:
byte[] rawBody = exchange.getRequestBody().readAllBytes();
String signature = exchange.getRequestHeaders().getFirst("X-CSL-Signature");
String keyId = exchange.getRequestHeaders().getFirst("X-CSL-Key-Id");
if (!signatureMatches(rawBody, signature, keyId)) {
exchange.sendResponseHeaders(401, -1);
return;
}
// ... read the payload ...
exchange.sendResponseHeaders(200, -1);const rawBody = Buffer.concat(chunks);
const signature = req.headers["x-csl-signature"];
const keyId = req.headers["x-csl-key-id"];
if (!signatureMatches(rawBody, signature, keyId)) {
res.writeHead(401).end();
return;
}
// ... read the payload ...
res.writeHead(200).end();length = int(self.headers.get("Content-Length", 0))
raw_body = self.rfile.read(length)
signature = self.headers.get("X-CSL-Signature", "")
key_id = self.headers.get("X-CSL-Key-Id", "")
if not signature_matches(raw_body, signature, key_id):
self.send_response(401)
self.end_headers()
return
# ... read the payload ...
self.send_response(200)
self.end_headers()<?php
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_CSL_SIGNATURE'] ?? '';
$keyId = $_SERVER['HTTP_X_CSL_KEY_ID'] ?? '';
if (!signature_matches($rawBody, $signature, $keyId)) {
http_response_code(401);
exit;
}
// ... read the payload ...
http_response_code(200);This server speaks plain HTTP, and your registered URL must be HTTPS. Run it behind the TLS terminator you already have, or use HttpsServer.
Managing Your Endpoint
Your endpoint has an id — the id field from the registration response, also listed by GET /api/v1/partners/{partnerId}/webhooks. Four calls change it, all on /webhook-notification:
| You want to | Call |
|---|---|
| Add or drop event types | PATCH /api/v1/partners/{partnerId}/webhooks/{endpointId} with the complete events list |
| Move to a new URL | the same PATCH with url |
| Pause during a deploy | the same PATCH with {"status": "PAUSED"}; resume with {"status": "ACTIVE"} |
| Stop entirely | DELETE /api/v1/partners/{partnerId}/webhooks/{endpointId} |
Three things worth knowing before you use them.
events replaces, it does not merge. Send every event type you want on the URL, not just the new one. To add collection.refund.failed to an endpoint that already has two event types, send all three.
Changing url re-runs the challenge. Proving you own the old address says nothing about the new one, so the endpoint drops to PENDING_VERIFICATION and the challenge is posted to the new URL inside the same PATCH. Deploy the new handler first and you will get ACTIVE straight back, with no interruption. Deploy it afterwards and events are held until you retry the verify call.
Pause acts on the URL, not on one event. Every event type routed to the endpoint is held, which is usually what you want during a deploy — events queue instead of failing, so you burn no retry budget and do not trip the suspension threshold. Held events go out on resume, and expire after 72 hours. DELETE is the opposite: nothing is held, and re-registering the URL later creates a fresh endpoint that must pass the challenge again.
Re-registering a URL you already have is not an error — POST .../webhooks updates it in place and keeps its verification, so you can safely run it from a deploy script on every release. The one thing it will not do quietly is take an event type away from another of your endpoints; that comes back as 409 EVENT_ALREADY_ROUTED, naming the URL that currently holds it.
Troubleshooting
| Symptom | Most likely cause |
|---|---|
| Registered the URL, nothing ever arrives | The registration response said PENDING_VERIFICATION. Read activation.error in it, fix the handler, and call the nextStep path |
| Every delivery returns 401 from your side | The body was parsed before hashing, or the secret was base64-decoded first |
| Worked for weeks, then every delivery failed | A secret was rotated and your service holds only one. Read X-CSL-Key-Id |
| Signature fails only in production | A gateway or WAF in front of your service is re-emitting the JSON body. Pass it through untouched, or verify at the edge |
| Events stopped after a burst of failures | The endpoint was paused after 20 consecutive failures — one count for the URL, whatever it is subscribed to. It resumes on its own |
| The same payment processed twice | No de-duplication on eventId, or a SELECT-then-INSERT that lost a race |
| A test event reached your ledger | The envelope's "test": true flag is not being checked |
| Sandbox secret rejected in production | Each environment has its own secrets |
Your delivery history — every attempt, what you returned, and what we did about it — is in your console under Deliveries. Quote X-CSL-Delivery-Id when raising a ticket.
Getting Help
Registering endpoints, rotating secrets and replaying deliveries are all self-service, in your console or through the API above. For anything else, email support@cslpay.io with your partner id and the X-CSL-Delivery-Id of an affected delivery.
Registering an endpoint, or rotating a signing secret? Email technology@cslcapitaluk.com.