Bank cards

Accept Visa and Mastercard on your site. You start the payment from your server, KPay hosts the page where the customer enters their card, and notifies you of the result. One integration: only your keys change between test and production.

Sandbox mode. Use your kpay_test_... keys and the test numbers below. KPay routes your requests to its test environment — no real money is moved.

One integration, two environments

The code you write does not change between testing and production: same endpoints, same statuses, same signed webhooks — and above all the same card form, served by Stripe in both cases. Only the API key decides whether money actually moves.

Environments

The API key you send decides the environment — there is nothing else to configure.

API keyBehaviour
kpay_live_…Real payment: the customer is charged and funds land in your production wallet.
kpay_test_…Test payment: real Stripe form, test cards only, test wallet. No real money.

Never mix your keys

A production key charges for real from the very first call. Check your key prefix before every test run.

Before you start

Three things to check — they account for nearly every integration error.

Card is enabled on your application

Tick CARD (or VISA / MASTERCARD) in your application's payment methods. Otherwise the call fails with a 400.

Both your keys are sent

X-API-Key and X-Secret-Key are both required. Miss one and you get a 401.

Your webhook endpoint is publicly reachable

KPay refuses to call a private address (localhost, 127.0.0.1, 10.x, 192.168.x) — this is SSRF protection. In development, expose your server with a tunnel: ngrok, Cloudflare Tunnel, localtunnel.

Taking a payment

The sequence is identical in both environments, from the first call through to the final webhook.

  1. You initiate the payment from your server.
  2. KPay replies with a payment URL (`gatewayUrl`) and status `PENDING`.
  3. You redirect the customer there: they pick card, enter their number in the Stripe form and complete 3-D Secure if their bank requires it.
  4. KPay updates the status and sends the webhook to your server.
  5. The customer is redirected to your `returnUrl`.

Initiate a payment

Request body

amountnumberrequis

Amount to collect, in USD — the only currency on the card rail. "4" means USD 4.00, not 4 XAF. Minimum USD 1. Do not send a "currency" field: it does not exist on this endpoint.

paymentMethod"CARD"

Requested method. `CARD` opens the payment page even if your application is set to USSD — you have no setting to change. Omit this field for Mobile Money.

externalIdstringrequis

Your order identifier. Unique per application: replaying the same call returns the existing transaction instead of creating a second one.

returnUrlstringrequis

URL the customer returns to after payment. Required.

cancelUrlstring

Return URL if the customer cancels. Defaults to returnUrl.

customerEmailstring

Customer email, used for the receipt.

descriptionstring

Label shown to the customer on the payment page.

The amount is in USD

On the card rail, "amount" is expressed in US dollars, exclusively. KPay converts to XAF for the partner at a rate locked in at initiation — you and your customer see the same amount throughout — then credits your wallet in USD. This is the key difference from Mobile Money, where the currency is that of the operator's country.
Node.js
const res = await fetch("https://test.admin.kpay.site/api/v1/payments/init", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.KPAY_API_KEY,
    "X-Secret-Key": process.env.KPAY_SECRET_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "amount": 25,
  "paymentMethod": "CARD",
  "externalId": "CMD-2026-001",
  "returnUrl": "https://votre-site.com/merci",
  "cancelUrl": "https://votre-site.com/panier",
  "description": "Commande #2026-001",
  "customerEmail": "client@example.com"
}),
});
const data = await res.json();

No setting to change

The receive mode (USSD or hosted page) applies to Mobile Money only. Requesting CARD always opens the payment page, whatever that setting says.

Response

Redirect the customer to `gatewayUrl`. The `isTest` field tells you the environment: handle the response the same way in both cases.

200 OK
{
  "id": "9f1c8a2e-...",
  "reference": "KPAY-A1B2C3D4",
  "externalId": "CMD-2026-001",
  "status": "PENDING",
  "mode": "GATEWAY",
  "amount": 25,
  "gatewayUrl": "https://pay.kpay.cm/pay/gw_YbSiijvNB0rRrPJ5...",
  "isTest": true
}

Test cards

Test mode mounts the real Stripe form. Enter one of these numbers there: it decides the outcome, exactly as in production.

NumberNetworkResult
Visa3-D Secure required — authentication step, then payment accepted
VisaPayment accepted
VisaInsufficient funds
VisaCard declined by the bank
Visa3-D Secure required, then declined after authentication
MastercardPayment accepted

Expiry date: any future date. CVC: any three digits. These are Stripe's test cards: they only work with a kpay_test_ key.

Unknown number

Any card outside this list is declined, just as a real bank would. This is deliberate: a made-up number must never produce a false success.

Failure reasons

When a payment fails, `failureReason` explains why in terms you can show your customer.

SituationfailureReason returned
Card declined by the bankCarte refusée par la banque émettrice.
Insufficient fundsProvision insuffisante sur le moyen de paiement.
Expired cardCarte expirée. Vérifiez la date de validité.

Customer-ready messages

These messages are meant to be shown as-is: they never name the technical partner and expose no internal detail.

Customer abandons

In test as in production, a customer may close the tab without paying. The transaction then stays pending and no webhook is sent. To reproduce it, open the payment page then close it without submitting the form.

The most commonly missed case

An abandoned transaction is neither a success nor a failure: it stays pending. Make sure your integration does not treat it as paid, and set an order expiry.

Statuses

Only three outcomes, and only one means you got paid.

StatusWhat it meansWhat you do
PENDINGPayment created, the customer has not paid yet.Nothing. Wait.
PROCESSINGThe customer is on the payment page.Nothing. Wait for the webhook.
COMPLETEDYou have been paid, your wallet is credited.Fulfil the order.
FAILEDCard declined, or authentication failed.Offer another payment method.
CANCELLEDSession expired or cancelled by the customer.The cart stays open.

The browser return

KPay appends to your returnUrl: status, reference, externalId, ts and sig. The signature covers the string "status|reference|externalId|ts", as HMAC-SHA256 with your application's gateway secret.

verify-return.js
import crypto from "crypto";

/**
 * Vérifie les paramètres ajoutés à votre returnUrl par KPay.
 *
 * Sert à afficher le bon message au client. Ne livrez JAMAIS une commande
 * sur cette seule base : ces paramètres passent par son navigateur.
 * C'est le webhook qui fait foi.
 */
export function verifyReturn(query, gatewaySecret) {
  const { status, reference, externalId, ts, sig } = query;

  const expected = crypto
    .createHmac("sha256", gatewaySecret)
    .update(`${status}|${reference}|${externalId ?? ""}|${ts}`)
    .digest("hex");

  return sig === expected;
}

The browser return

These parameters travel through the customer's browser. Always verify the signature, and never treat the redirect as proof of payment — that is the webhook's job.

Common errors

What you will see most often, and why.

ErrorCause and fix
401 UnauthorizedOne of the two keys is missing, or they do not match. Check X-API-Key AND X-Secret-Key.
400 — paymentMethodCARD is not enabled on this application. Tick it in its payment methods.
400 — amountAmount below USD 1. Remember: the amount is in dollars, not francs.
400 — returnUrlreturnUrl missing. It is required for a card payment.
409 ConflictThis externalId already has an active transaction. Use a different one, or fetch the existing transaction.
Aucun webhook reçuYour endpoint is not publicly reachable, or points to a private address KPay refuses to call. Use a tunnel in development.

Webhooks

Webhooks are sent and signed the same way in both environments. The `isTest` field lets you tell them apart if you use one URL for both testing and production.

Webhook
POST /votre-endpoint HTTP/1.1
X-KPay-Signature: 8f2c9a...
Content-Type: application/json

{
  "event": "payment.completed",
  "reference": "KPAY-A1B2C3D4",
  "externalId": "CMD-2026-001",
  "amount": 25,
  "currency": "USD",
  "status": "COMPLETED",
  "isTest": true
}

Verify the signature

Every webhook carries an X-KPAY-Signature header: an HMAC-SHA256 of the raw request body, computed with your application's secret. Compare it before processing anything — this is what tells a genuine KPay notification from a forged request. Use the RAW body, not the re-serialised object: regenerating the JSON changes whitespace and breaks the signature.

Node.js
import express from "express";
import crypto from "crypto";

const app = express();

// express.raw, PAS express.json : la signature porte sur les octets reçus.
app.post(
  "/webhooks/kpay",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-kpay-signature"];
    const expected = crypto
      .createHmac("sha256", process.env.KPAY_WEBHOOK_SECRET)
      .update(req.body)              // le Buffer brut
      .digest("hex");

    if (signature !== expected) {
      return res.status(401).send("signature invalide");
    }

    const event = JSON.parse(req.body.toString());

    // Répondez 200 vite, traitez ensuite : un traitement lent fait
    // retenter KPay et vous recevrez le même événement plusieurs fois.
    res.sendStatus(200);

    if (event.status === "COMPLETED") {
      // Idempotence : cette commande est peut-être déjà livrée.
      fulfillOrderOnce(event.externalId, event.reference);
    }
  },
);

app.listen(3000);

Replay without double-crediting

A webhook may arrive twice — that is normal, and every reliable notification system produces duplicates. Before fulfilling an order, check it has not been fulfilled already, keyed on your externalId.

In local development

Your endpoint must be publicly reachable to receive webhooks. Locally, expose it through a tunnel (ngrok, Cloudflare Tunnel) and set the resulting URL.

Going live

Once your tests pass:

  1. Swap your test keys for production keys.
  2. Check that your webhook and return URLs point to your real domain.
  3. Run one small real payment before opening up traffic.

Related resources

Was this page helpful?

K-PAY — Mobile Money and card payments across Central Africa.