CSL Pay Developers

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:

  1. Create a signing secret.
  2. Register your URL and its events. One call. If your handler is already running, that call comes back ACTIVE and 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 needWhere it comes from
A signing secretPOST /api/v1/partners/{partnerId}/webhooks/keys, or the Signing key screen in your console
An HTTPS callback URLYours. Public internet, port 443, 80 or 8443. localhost and private addresses are rejected
A handler at that URLHave it deployed before you register and you will be live in one call
Your partner idShown 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:

Example
@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")));
    }
}

Then retry with the nextStep path. What usually goes wrong:

activation.error saysCause
returned 200 with an empty bodyThe handler returned without the challenge
no "challenge" fieldReturning {"status":"ok"} or similar. The field must be named challenge
not JSONAn HTML error page. The body is parsed and compared exactly
could not reach your endpointNot 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.
Example
@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));
    }
}
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.

Example
@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();
    }
}

Note @RequestBody byte[]. Binding to a DTO would let the framework parse the body before you verify it.

Delivery Headers

HeaderValueUse it for
Content-Typeapplication/jsonAlways JSON
X-CSL-Signaturebase64 digestRequired. HMAC-SHA512 of the raw body
X-CSL-Key-Ide.g. v1Required. Which secret signed this delivery
X-CSL-Event-Ide.g. evt_518b…De-duplication. Identical on every retry and replay
X-CSL-Evente.g. remittance.payout.successfulRouting before parsing
X-CSL-Event-Version1.0Payload schema version
X-CSL-Delivery-Ida UUIDThis attempt. Quote it in a support ticket
X-CSL-Attempt1 on the first tryLogging. 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": { }
}
FieldMeaning
eventIdIdentifies the business event. Identical on every retry and every replay — de-duplicate on it
eventVersionPayload schema version
partnerIdYour partner id
eventTypeWhich of the five events this is
occurredAtWhen the money moved — not when the message was sent
dataThe 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

EventFires when
remittance.payout.successfulThe payout reached the beneficiary and is final
remittance.payout.failedThe payout could not be completed
collection.payment.successfulA payment was received and your virtual account credited
collection.refund.completedA refund reached the payer
collection.refund.failedA refund could not be paid out

Two differences worth knowing before you write a handler

The amount is shaped differently per family.

RemittanceCollection
amount{ "currency", "value" }{ "currency", "gross", "fee", "net" }

The "did the money come back" flag is spelled differently.

EventField
remittance.payout.failedbalanceRestored (singular)
collection.refund.failedbalancesRestored (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

FieldTypeNotes
partnerReferencestringYour own reference
transactionReferencestringThe payout's reference
statusstring
amountobjectcurrency, value
creditorobjectname, accountNumber, bankDetail{name, bankCode, swiftCode}
resultobjectresponseCode — stable across processors, branch on this — and message
balanceRestoredbooleanfailed only. true = your wallet debit has been reversed
completedAt / failedAttimestamp
{
  "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

FieldTypeNotes
partnerReferencestringYour own reference
transactionReferencestringThe bank's reference, not ours
statusstring
amountobjectcurrency, gross, fee, net
paymentRailobjectname
debtor / creditorobjectThe payer, and the virtual account credited
merchantId, subMerchantIdstring
valueDatedate
settledAttimestamp
stanstringRail 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

FieldTypeNotes
partnerReferencestring
transactionReferencestring
status, refundType, reasonstring
amountobjectcurrency, gross, fee, net
paymentRail, debtor, creditorobject
originalCollectionobjectpartnerReference, transactionReference, amount, settledAt — matches the refund to the original payment without a second lookup
merchantId, subMerchantIdstring
balancesRestoredbooleanfailed only. true = the merchant was debited and has been credited back; false = no money moved
completedAt / failedAttimestamp
failureReasonobjectcode (stable), message, railResponseCode. Always present, always null on a completed refund

Responding to a Delivery

You returnWe do
Any 2xxDelivered. No further attempts
408, 429, any 5xx, or no answerRetry on the ladder below
429 with Retry-After: 120Wait exactly 120 seconds. Honoured up to 24 hours
410 GoneStop permanently. That delivery is never retried
Any 3xxTreated as rejected — we do not follow redirects
Any other 4xxRejected, and capped at 3 attempts rather than the full ladder

Two consequences worth planning for:

  • A 401 from 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 301 will 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 → 6h

rateLimitPerSecond 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 NOTHING

A 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."
}
  1. Call rotate and capture the new secret.
  2. Add it to your configuration alongside the old one.
  3. Deploy. Both now verify, and X-CSL-Key-Id says which signed each delivery.
  4. 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:

Example
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();

Reading and verifying, with no framework in the way:

Example
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);

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 toCall
Add or drop event typesPATCH /api/v1/partners/{partnerId}/webhooks/{endpointId} with the complete events list
Move to a new URLthe same PATCH with url
Pause during a deploythe same PATCH with {"status": "PAUSED"}; resume with {"status": "ACTIVE"}
Stop entirelyDELETE /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

SymptomMost likely cause
Registered the URL, nothing ever arrivesThe registration response said PENDING_VERIFICATION. Read activation.error in it, fix the handler, and call the nextStep path
Every delivery returns 401 from your sideThe body was parsed before hashing, or the secret was base64-decoded first
Worked for weeks, then every delivery failedA secret was rotated and your service holds only one. Read X-CSL-Key-Id
Signature fails only in productionA 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 failuresThe 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 twiceNo de-duplication on eventId, or a SELECT-then-INSERT that lost a race
A test event reached your ledgerThe envelope's "test": true flag is not being checked
Sandbox secret rejected in productionEach 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.