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

  1. Go to SettingsWebhooks in your Credo dashboard
  2. Enter your webhook endpoint URL (must be HTTPS)
  3. Save

Your endpoint should be a publicly accessible URL on your server that can receive POST requests.

Implement your endpoint

Your webhook endpoint must:

  1. Accept HTTP POST requests with a JSON body
  2. Verify the signature before processing
  3. Respond with HTTP 200 quickly (within 5 seconds)
  4. Process the event asynchronously if needed

Webhook events

Credo sends webhooks for the following events:

EventDescription
transaction.successfulThe customer's payment was successfully processed and accepted.
transaction.failedThe customer's payment was declined — possible issues with payment method or insufficient funds.
transaction.transaction.transfer.reverseA bank transfer was underpaid or overpaid. The system detected the discrepancy and automatically reversed the transfer back to the customer.
transaction.settlement.successThe 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

FieldTypeDescription
eventstringThe event type (see table above)
data.businessCodestringYour Credo business code
data.transRefstringCredo's transaction reference — use this for verification and idempotency
data.businessRefstringYour application's reference for the transaction
data.debitedAmountnumberTotal amount debited from the customer
data.transAmountnumberThe base transaction amount
data.transFeeAmountnumberProcessing fee charged
data.settlementAmountnumberAmount that will be settled to you
data.customerIdstringCustomer's email address
data.transactionDatestringWhen the transaction occurred
data.channelIdnumberPayment channel identifier
data.currencyCodestringCurrency code (NGN, USD)
data.statusnumberTransaction status code (0 = successful)
data.paymentMethodTypestringSpecific method (e.g., MasterCard, Visa)
data.paymentMethodstringPayment channel (e.g., Card, Bank)
data.customerobjectCustomer 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:

  • secretKey is your Credo secret key
  • businessCode is 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:

AttemptDelay
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

On this page