Webhooks (Callbacks)
Centiwise sends an HTTP POST request to your server_callback_url whenever a transaction status changes. Webhooks are the primary way to learn the final outcome of processing transactions.
Callback Payload
{
"status": "success",
"orderid": "CW-987654",
"client_orderid": "ORD-001",
"amount": 1000,
"currency": "KES"
}The payload mirrors the standard API response structure. The status field will be success, error, or declined.
Endpoint Requirements
Your server_callback_url must meet these requirements:
| Requirement | Detail |
|---|---|
| Protocol | HTTPS with a valid TLS certificate (TLS 1.2 or higher) |
| Method accepted | POST |
| Response | Return HTTP 200 within 10 seconds |
| Idempotency | Handle duplicate deliveries — the same client_orderid may arrive more than once |
| Publicly accessible | The URL must be reachable from the internet (no localhost or private IPs) |
Recommended Implementation Pattern
Acknowledge immediately, process asynchronously. Never perform slow operations (database writes, third-party calls) before returning the 200. Queue the payload and process it in the background.
Node.js (Express)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/callback', async (req, res) => {
// 1. Acknowledge immediately
res.sendStatus(200);
// 2. Process asynchronously
const { status, client_orderid, orderid, amount, currency } = req.body;
try {
// Idempotency check — skip if already processed
const existing = await db.orders.findOne({ client_orderid });
if (existing?.status === 'paid') return;
if (status === 'success') {
await db.orders.update(client_orderid, { status: 'paid', gateway_id: orderid });
await fulfillOrder(client_orderid);
} else {
await db.orders.update(client_orderid, { status: 'failed' });
}
} catch (err) {
console.error('Callback processing error:', err);
// Log and alert — Centiwise will retry
}
});Python (Flask)
from flask import Flask, request, jsonify
import threading
app = Flask(__name__)
@app.route('/callback', methods=['POST'])
def callback():
payload = request.get_json()
# Acknowledge immediately
thread = threading.Thread(target=process_callback, args=(payload,))
thread.start()
return '', 200
def process_callback(payload):
client_orderid = payload.get('client_orderid')
status = payload.get('status')
# Idempotency check
order = db.orders.find_one(client_orderid)
if order and order['status'] == 'paid':
return
if status == 'success':
db.orders.update(client_orderid, {'status': 'paid'})
fulfill_order(client_orderid)
else:
db.orders.update(client_orderid, {'status': 'failed'})Idempotency
Centiwise may deliver the same callback more than once (network retries, provider re-notifications). Your handler must be idempotent:
// Before processing, check if this order was already finalised
const order = await db.orders.findOne({ client_orderid });
if (order && ['paid', 'failed'].includes(order.status)) {
// Already processed — do nothing
return;
}
// Safe to processTesting Your Callback Endpoint
Use a tool like ngrok to expose a local server during development:
ngrok http 3000
# Gives you a public URL like https://abc123.ngrok.io
# Set your server_callback_url to:
# https://abc123.ngrok.io/callbackTroubleshooting
| Issue | Likely Cause | Fix |
|---|---|---|
| Callback not received | URL not publicly accessible | Check firewall rules and ensure the URL is reachable from the internet |
Callback received but no 200 | Error in your handler before the response | Move res.sendStatus(200) to the very first line of your handler |
| Duplicate callbacks processed | Missing idempotency check | Check client_orderid against your database before processing |
| SSL error | Invalid or self-signed certificate | Use a valid certificate from a trusted CA (Let's Encrypt is free) |
Updated 5 months ago
