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

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

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

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.