Webhooks
Receive real-time notifications when payment events occur on your Credo account.
Webhooks let Credo push event notifications to your server as they happen. Instead of polling the API to check if a payment completed, your server receives an HTTP POST request the moment the transaction status changes.
Why use webhooks
Relying solely on the callback redirect has gaps:
- The customer may close their browser before the redirect
- Network issues can prevent the redirect from completing
- Mobile app users may switch away from the browser
Webhooks are delivered server-to-server and are the most reliable way to confirm transaction outcomes.
Setting up webhooks
Add your webhook URL
- Go to Settings → Webhooks in your Credo dashboard
- Enter your webhook endpoint URL (must be HTTPS)
- Save
Your endpoint should be a publicly accessible URL on your server that can receive POST requests.
Implement your endpoint
Your webhook endpoint must:
- Accept HTTP POST requests with a JSON body
- Verify the signature before processing
- Respond with HTTP
200quickly (within 5 seconds) - Process the event asynchronously if needed
Webhook events
Credo sends webhooks for the following events:
| Event | Description |
|---|---|
transaction.successful | The customer's payment was successfully processed and accepted. |
transaction.failed | The customer's payment was declined — possible issues with payment method or insufficient funds. |
transaction.transaction.transfer.reverse | A bank transfer was underpaid or overpaid. The system detected the discrepancy and automatically reversed the transfer back to the customer. |
transaction.settlement.success | The merchant's settlement from a transaction has been paid out. |
Your webhook handler should account for all event types you care about. At minimum, handle transaction.successful and transaction.failed.
Webhook payload
When an event occurs, Credo sends a POST request to your webhook URL with a JSON body containing an event field and a data object:
{
"event": "transaction.successful",
"data": {
"businessCode": "700607002190001",
"transRef": "cI9H00N2AB02Qb0s69Mj",
"businessRef": "PL1683423455304ATm",
"debitedAmount": 1000.0,
"transAmount": 1000.0,
"transFeeAmount": 15.0,
"settlementAmount": 985.0,
"customerId": "customer@example.com",
"transactionDate": "May 7, 2023, 1:37:53 AM",
"channelId": 1,
"currencyCode": "NGN",
"status": 0,
"paymentMethodType": "MasterCard",
"paymentMethod": "Card",
"customer": {
"customerEmail": "customer@example.com",
"firstName": "John",
"lastName": "Doe",
"phoneNo": "23470122199999"
}
}
}Payload fields
| Field | Type | Description |
|---|---|---|
event | string | The event type (see table above) |
data.businessCode | string | Your Credo business code |
data.transRef | string | Credo's transaction reference — use this for verification and idempotency |
data.businessRef | string | Your application's reference for the transaction |
data.debitedAmount | number | Total amount debited from the customer |
data.transAmount | number | The base transaction amount |
data.transFeeAmount | number | Processing fee charged |
data.settlementAmount | number | Amount that will be settled to you |
data.customerId | string | Customer's email address |
data.transactionDate | string | When the transaction occurred |
data.channelId | number | Payment channel identifier |
data.currencyCode | string | Currency code (NGN, USD) |
data.status | number | Transaction status code (0 = successful) |
data.paymentMethodType | string | Specific method (e.g., MasterCard, Visa) |
data.paymentMethod | string | Payment channel (e.g., Card, Bank) |
data.customer | object | Customer details — email, name, and phone number |
Verifying webhook signatures
Every webhook request includes an X-Credo-Signature header. You must verify this signature to ensure the request genuinely came from Credo and hasn't been tampered with.
The signature is calculated as SHA512(secretKey + businessCode) where:
secretKeyis your Credo secret keybusinessCodeis your Credo business code (found in the webhook payload or dashboard)
import crypto from "crypto";
function verifyWebhook(req, secretKey, businessCode) {
const signature = req.headers["x-credo-signature"];
const expected = crypto
.createHash("sha512")
.update(secretKey + businessCode)
.digest("hex");
return signature === expected;
}
// Express.js example
app.post("/webhooks/credo", express.json(), (req, res) => {
const secretKey = process.env.CREDO_SECRET_KEY;
const businessCode = req.body.data.businessCode;
if (!verifyWebhook(req, secretKey, businessCode)) {
return res.status(401).send("Invalid signature");
}
const { event, data } = req.body;
switch (event) {
case "transaction.successful":
fulfillOrder(data);
break;
case "transaction.failed":
markOrderFailed(data);
break;
case "transaction.transaction.transfer.reverse":
handleTransferReversal(data);
break;
case "transaction.settlement.success":
recordSettlement(data);
break;
}
res.status(200).send("OK");
});import hashlib
from flask import Flask, request
app = Flask(__name__)
def verify_webhook(request, secret_key, business_code):
signature = request.headers.get("X-Credo-Signature", "")
expected = hashlib.sha512((secret_key + business_code).encode()).hexdigest()
return signature == expected
@app.route("/webhooks/credo", methods=["POST"])
def handle_webhook():
secret_key = os.environ["CREDO_SECRET_KEY"]
business_code = request.json["data"]["businessCode"]
if not verify_webhook(request, secret_key, business_code):
return "Invalid signature", 401
event = request.json["event"]
data = request.json["data"]
match event:
case "transaction.successful":
fulfill_order(data)
case "transaction.failed":
mark_order_failed(data)
case "transaction.transaction.transfer.reverse":
handle_transfer_reversal(data)
case "transaction.settlement.success":
record_settlement(data)
return "OK", 200<?php
function verifyWebhook($signature, $secretKey, $businessCode) {
$expected = hash("sha512", $secretKey . $businessCode);
return $signature === $expected;
}
$signature = $_SERVER["HTTP_X_CREDO_SIGNATURE"] ?? "";
$secretKey = getenv("CREDO_SECRET_KEY");
$body = file_get_contents("php://input");
$payload = json_decode($body, true);
$businessCode = $payload["data"]["businessCode"];
if (!verifyWebhook($signature, $secretKey, $businessCode)) {
http_response_code(401);
exit("Invalid signature");
}
$event = $payload["event"];
$data = $payload["data"];
switch ($event) {
case "transaction.successful":
fulfillOrder($data);
break;
case "transaction.failed":
markOrderFailed($data);
break;
case "transaction.transaction.transfer.reverse":
handleTransferReversal($data);
break;
case "transaction.settlement.success":
recordSettlement($data);
break;
}
http_response_code(200);
echo "OK";import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import spark.Request;
import spark.Response;
import static spark.Spark.*;
public class WebhookHandler {
private static final String SECRET_KEY = System.getenv("CREDO_SECRET_KEY");
private static final ObjectMapper mapper = new ObjectMapper();
public static void main(String[] args) {
post("/webhooks/credo", (req, res) -> handleWebhook(req, res));
}
private static String handleWebhook(Request req, Response res) {
try {
String signature = req.headers("X-Credo-Signature");
String body = req.body();
JsonNode payload = mapper.readTree(body);
String businessCode = payload.get("data").get("businessCode").asText();
if (!verifyWebhook(signature, businessCode)) {
res.status(401);
return "Invalid signature";
}
String event = payload.get("event").asText();
JsonNode data = payload.get("data");
switch (event) {
case "transaction.successful":
fulfillOrder(data);
break;
case "transaction.failed":
markOrderFailed(data);
break;
case "transaction.transaction.transfer.reverse":
handleTransferReversal(data);
break;
case "transaction.settlement.success":
recordSettlement(data);
break;
}
res.status(200);
return "OK";
} catch (Exception e) {
res.status(500);
return "Error processing webhook";
}
}
private static boolean verifyWebhook(String signature, String businessCode) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-512");
byte[] hash = digest.digest((SECRET_KEY + businessCode).getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString().equals(signature);
}
private static void fulfillOrder(JsonNode data) { /* Implementation */ }
private static void markOrderFailed(JsonNode data) { /* Implementation */ }
private static void handleTransferReversal(JsonNode data) { /* Implementation */ }
private static void recordSettlement(JsonNode data) { /* Implementation */ }
}using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
[ApiController]
[Route("webhooks/credo")]
public class WebhookController : ControllerBase
{
private readonly string _secretKey;
public WebhookController(IConfiguration config)
{
_secretKey = config["CREDO_SECRET_KEY"];
}
[HttpPost]
public IActionResult HandleWebhook([FromBody] JsonElement payload)
{
string signature = Request.Headers["X-Credo-Signature"];
string businessCode = payload.GetProperty("data").GetProperty("businessCode").GetString();
if (!VerifyWebhook(signature, businessCode))
{
return Unauthorized("Invalid signature");
}
string eventType = payload.GetProperty("event").GetString();
JsonElement data = payload.GetProperty("data");
switch (eventType)
{
case "transaction.successful":
FulfillOrder(data);
break;
case "transaction.failed":
MarkOrderFailed(data);
break;
case "transaction.transaction.transfer.reverse":
HandleTransferReversal(data);
break;
case "transaction.settlement.success":
RecordSettlement(data);
break;
}
return Ok("OK");
}
private bool VerifyWebhook(string signature, string businessCode)
{
using (var sha512 = SHA512.Create())
{
byte[] inputBytes = Encoding.UTF8.GetBytes(_secretKey + businessCode);
byte[] hashBytes = sha512.ComputeHash(inputBytes);
string expected = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
return expected.Equals(signature, StringComparison.OrdinalIgnoreCase);
}
}
private void FulfillOrder(JsonElement data) { /* Implementation */ }
private void MarkOrderFailed(JsonElement data) { /* Implementation */ }
private void HandleTransferReversal(JsonElement data) { /* Implementation */ }
private void RecordSettlement(JsonElement data) { /* Implementation */ }
}Always verify signatures
Never process a webhook without verifying the X-Credo-Signature header. Without verification, anyone could send fake payment notifications to your endpoint.
Handling webhooks correctly
Respond quickly
Return HTTP 200 within 5 seconds. If your processing takes longer, acknowledge the webhook first and handle the business logic asynchronously (e.g., using a message queue).
app.post("/webhooks/credo", express.json(), (req, res) => {
if (!verifyWebhook(req)) {
return res.status(401).send("Invalid signature");
}
// Acknowledge immediately
res.status(200).send("OK");
// Process asynchronously
queue.push(req.body);
});Handle duplicates (idempotency)
Credo may send the same webhook more than once (e.g., during retries). Use the transRef as an idempotency key:
async function handleWebhookEvent(event, data) {
// Check if already processed
const existing = await db.webhookEvents.findOne({
transRef: data.transRef,
event: event,
});
if (existing) {
return; // Already processed - skip
}
// Record the event
await db.webhookEvents.insert({
transRef: data.transRef,
businessRef: data.businessRef,
event: event,
amount: data.transAmount,
processedAt: new Date(),
});
// Handle by event type
switch (event) {
case "transaction.successful":
await fulfillOrder(data.businessRef);
break;
case "transaction.failed":
await notifyCustomerOfFailure(data);
break;
// ... other events
}
}Always verify the transaction
Even after receiving a transaction.successful webhook, call the verification endpoint to confirm the transaction details. This provides defense-in-depth:
async function fulfillOrder(webhookData) {
// Double-check with the API
const response = await fetch(
`https://api.credocentral.com/transaction/${webhookData.transRef}/verify`,
{ headers: { "Authorization": process.env.CREDO_SECRET_KEY } }
);
const { data } = await response.json();
if (data.status !== 0) {
return; // Transaction not actually successful
}
// Verify amount matches what you expect
if (data.transAmount !== expectedAmount) {
return; // Amount mismatch
}
// Now safe to fulfill
await deliverValue(data.businessRef);
}Retry behavior
If your endpoint doesn't respond with HTTP 200, Credo retries the webhook:
| Attempt | Delay |
|---|---|
| 1st retry | ~1 minute |
| 2nd retry | ~5 minutes |
| 3rd retry | ~30 minutes |
| 4th retry | ~2 hours |
| 5th retry | ~24 hours |
After 5 failed retries, the webhook is marked as failed. You can view failed webhooks in your dashboard and trigger manual retries.
If your endpoint consistently fails, check your server logs for errors. Common causes include: firewall blocking Credo's IPs, SSL certificate issues, or application errors.
Best practices
Use HTTPS
Your webhook URL must use HTTPS. Credo will not deliver webhooks to HTTP endpoints.
Verify every request
Always check the credo-signature header before processing any webhook.
Be idempotent
Handle duplicate deliveries gracefully using the transaction reference as a key.
Respond fast
Return HTTP 200 within 5 seconds. Process heavy work asynchronously.
Next steps
Was this page helpful?
Last updated on
