CSL Pay Developers

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

HeaderValueMeaning
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.

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

}

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.