Examples
Node.js
Collecting, disbursing and handling webhooks, end to end.
A complete Express integration: both ways to collect, sending money out, and a verified webhook.
Setup
const express = require('express');
const crypto = require('crypto');
const app = express();
const BASE = 'https://connect.tchokopay.com/v1';
// Two keys, not one. See /docs/authentication.
const COLLECT_KEY = process.env.TCHOKOPAY_COLLECT_KEY;
const DISBURSE_KEY = process.env.TCHOKOPAY_DISBURSE_KEY;
const WEBHOOK_SECRET = process.env.TCHOKOPAY_WEBHOOK_SECRET;
async function call(path, { key, method = 'GET', body, idempotencyKey } = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${key}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
const data = await res.json();
if (!res.ok) {
// Branch on `code`, never on the message — the wording can change.
const error = new Error(data.message);
error.code = data.code;
error.param = data.param;
error.requestId = data.requestId;
throw error;
}
return data;
}Charging a number directly
The customer stays in your app and approves a prompt on their phone.
async function chargePhone(orderId, amount, { phone, provider, country }) {
return call('/collect/charge', {
key: COLLECT_KEY,
method: 'POST',
idempotencyKey: `order-${orderId}`,
body: {
amount,
currency: 'XAF',
country,
provider,
phone,
merchantReference: orderId,
description: `Order ${orderId}`,
},
});
}
app.post('/pay', express.json(), async (req, res) => {
try {
const charge = await chargePhone(req.body.orderId, 5000, {
phone: req.body.phone,
provider: req.body.provider, // from GET /v1/account/providers
country: 'CM',
});
// Returns immediately. Nobody has paid yet — wait for the webhook.
res.json({ reference: charge.reference, status: charge.status });
} catch (err) {
if (err.code === 'INVALID_REQUEST') {
return res.status(400).json({ message: err.message, field: err.param });
}
throw err;
}
});Hosted checkout
If you would rather not handle numbers or network selection.
app.get('/checkout/:orderId', async (req, res) => {
const checkout = await call('/collect/checkout', {
key: COLLECT_KEY,
method: 'POST',
idempotencyKey: `order-${req.params.orderId}`,
body: {
amount: 5000,
currency: 'XAF',
description: `Order ${req.params.orderId}`,
reference: req.params.orderId,
},
});
res.redirect(checkout.checkoutUrl);
});Building your network picker
Fetch this once at start-up rather than hardcoding it.
const providers = await call('/account/providers', { key: COLLECT_KEY });
// [{ provider: 'mtn_cm', name: 'MTN MoMo', method: 'MOBILE_MONEY', country: 'CM' }, ...]
// Show `name` to your customer; send `provider` back to us.Sending money out
async function disburse(instructionId, amount, { phone, provider, country, name }) {
return call('/disburse', {
key: DISBURSE_KEY,
method: 'POST',
// Derive this from something stable in your own system. Never random —
// a retry must produce the same key, or it becomes a second payment.
idempotencyKey: `disburse-${instructionId}`,
body: {
amount,
currency: 'XAF',
country,
provider: provider,
phone: phone,
recipientName: name,
merchantReference: instructionId,
},
});
}Verifying webhooks
function verifyWebhook(rawBody, signatureHeader, timestampHeader, secret) {
const age = Math.abs(Date.now() / 1000 - Number(timestampHeader));
if (age > 300) return false; // stale — reject even if the signature matches
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestampHeader}.${rawBody}`)
.digest('hex');
const provided = (signatureHeader || '').replace('sha256=', '');
if (expected.length !== provided.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}
app.post('/webhooks/tchokopay', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString();
if (!verifyWebhook(raw, req.headers['x-tchokopay-signature'], req.headers['x-tchokopay-timestamp'], WEBHOOK_SECRET)) {
return res.status(400).send('invalid signature');
}
const eventType = req.headers['x-tchokopay-event'];
const event = JSON.parse(raw);
switch (eventType) {
case 'collect.succeeded':
markOrderPaid(event.invoiceReference, event.settledAmount, event.settledCurrency);
break;
case 'collect.failed':
markOrderFailed(event.invoiceReference, event.failureReason);
break;
case 'disburse.succeeded':
markInstructionPaid(event.reference);
break;
case 'disburse.failed':
markInstructionFailed(event.reference, event.statusReason);
break;
}
// Answer 2xx quickly. Do the slow work after.
res.sendStatus(200);
});
app.listen(3000);The webhook route uses express.raw(), not express.json() — signature verification
needs the exact bytes that were sent, before any parsing. See
Webhooks.
If your endpoint sits behind a redirect, register the final URL. We do not follow redirects on webhook delivery, and a redirecting endpoint counts as a failed delivery.