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

On this page