TchokoPay
Examples

Python

Collecting, disbursing and handling webhooks, end to end.

A complete Flask integration: both ways to collect, sending money out, and a verified webhook.

Setup

import hashlib
import hmac
import os
import time

import requests
from flask import Flask, jsonify, redirect, request

app = Flask(__name__)
BASE = "https://connect.tchokopay.com/v1"

# Two keys, not one. See /docs/authentication.
COLLECT_KEY = os.environ["TCHOKOPAY_COLLECT_KEY"]
DISBURSE_KEY = os.environ["TCHOKOPAY_DISBURSE_KEY"]
WEBHOOK_SECRET = os.environ["TCHOKOPAY_WEBHOOK_SECRET"]


class TchokoPayError(Exception):
    def __init__(self, payload):
        # Branch on `code`, never on the message — the wording can change.
        self.code = payload.get("code")
        self.param = payload.get("param")
        self.request_id = payload.get("requestId")
        super().__init__(payload.get("message", "Request failed"))


def call(path, *, key, method="GET", body=None, idempotency_key=None):
    headers = {"Authorization": f"Bearer {key}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    res = requests.request(method, f"{BASE}{path}", headers=headers, json=body, timeout=30)
    data = res.json()

    if not res.ok:
        raise TchokoPayError(data)

    return data

Charging a number directly

The customer stays in your app and approves a prompt on their phone.

def charge_phone(order_id, amount, *, phone, provider, country):
    return call(
        "/collect/charge",
        key=COLLECT_KEY,
        method="POST",
        idempotency_key=f"order-{order_id}",
        body={
            "amount": amount,
            "currency": "XAF",
            "country": country,
            "provider": provider,
            "phone": phone,
            "merchantReference": order_id,
            "description": f"Order {order_id}",
        },
    )


@app.post("/pay")
def pay():
    payload = request.get_json()
    try:
        charge = charge_phone(
            payload["orderId"],
            5000,
            phone=payload["phone"],
            provider=payload["provider"],  # from GET /v1/account/providers
            country="CM",
        )
    except TchokoPayError as err:
        if err.code == "INVALID_REQUEST":
            return jsonify(message=str(err), field=err.param), 400
        raise

    # Returns immediately. Nobody has paid yet — wait for the webhook.
    return jsonify(reference=charge["reference"], status=charge["status"])

Hosted checkout

If you would rather not handle numbers or network selection.

@app.get("/checkout/<order_id>")
def checkout(order_id):
    payment = call(
        "/collect/checkout",
        key=COLLECT_KEY,
        method="POST",
        idempotency_key=f"order-{order_id}",
        body={
            "amount": 5000,
            "currency": "XAF",
            "description": f"Order {order_id}",
            "merchantReference": order_id,
        },
    )
    return redirect(payment["checkoutUrl"])

Building your network picker

Fetch this once at start-up rather than hardcoding it.

providers = 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

def disburse(instruction_id, 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.
        idempotency_key=f"disburse-{instruction_id}",
        body={
            "amount": amount,
            "currency": "XAF",
            "country": country,
            "provider": provider,
            "phone": phone,
            "recipientName": name,
            "merchantReference": instruction_id,
        },
    )

Verifying webhooks

def verify_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    try:
        age = abs(time.time() - int(timestamp))
    except (TypeError, ValueError):
        return False

    if age > 300:  # stale — reject even if the signature matches
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    provided = (signature or "").replace("sha256=", "")
    return hmac.compare_digest(expected, provided)


@app.post("/webhooks/tchokopay")
def webhook():
    raw = request.get_data()  # raw bytes, before any parsing

    if not verify_webhook(
        raw,
        request.headers.get("X-TchokoPay-Signature"),
        request.headers.get("X-TchokoPay-Timestamp"),
        WEBHOOK_SECRET,
    ):
        return "invalid signature", 400

    event_type = request.headers.get("X-TchokoPay-Event")
    event = request.get_json()

    if event_type == "collect.succeeded":
        mark_order_paid(event["invoiceReference"], event["settledAmount"], event["settledCurrency"])
    elif event_type == "collect.failed":
        mark_order_failed(event["invoiceReference"], event.get("failureReason"))
    elif event_type == "disburse.succeeded":
        mark_instruction_paid(event["reference"])
    elif event_type == "disburse.failed":
        mark_instruction_failed(event["reference"], event.get("statusReason"))

    # Answer 2xx quickly. Do the slow work after.
    return "", 200

Use request.get_data(), not request.get_json(), for verification — the signature is over the exact bytes that were sent. 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