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.
One integration, two environments
Environments
The API key you send decides the environment — there is nothing else to configure.
| API key | Behaviour |
|---|---|
| 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
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.
- You initiate the payment from your server.
- KPay replies with a payment URL (`gatewayUrl`) and status `PENDING`.
- 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.
- KPay updates the status and sends the webhook to your server.
- The customer is redirected to your `returnUrl`.
Initiate a payment
Request body
amountnumberrequisAmount 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.
externalIdstringrequisYour order identifier. Unique per application: replaying the same call returns the existing transaction instead of creating a second one.
returnUrlstringrequisURL the customer returns to after payment. Required.
cancelUrlstringReturn URL if the customer cancels. Defaults to returnUrl.
customerEmailstringCustomer email, used for the receipt.
descriptionstringLabel shown to the customer on the payment page.
The amount is in USD
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
Response
Redirect the customer to `gatewayUrl`. The `isTest` field tells you the environment: handle the response the same way in both cases.
{
"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.
| Number | Network | Result |
|---|---|---|
| Visa | 3-D Secure required — authentication step, then payment accepted | |
| Visa | Payment accepted | |
| Visa | Insufficient funds | |
| Visa | Card declined by the bank | |
| Visa | 3-D Secure required, then declined after authentication | |
| Mastercard | Payment 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
Failure reasons
When a payment fails, `failureReason` explains why in terms you can show your customer.
| Situation | failureReason returned |
|---|---|
| Card declined by the bank | Carte refusée par la banque émettrice. |
| Insufficient funds | Provision insuffisante sur le moyen de paiement. |
| Expired card | Carte expirée. Vérifiez la date de validité. |
Customer-ready messages
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
Statuses
Only three outcomes, and only one means you got paid.
| Status | What it means | What you do |
|---|---|---|
PENDING | Payment created, the customer has not paid yet. | Nothing. Wait. |
PROCESSING | The customer is on the payment page. | Nothing. Wait for the webhook. |
COMPLETED | You have been paid, your wallet is credited. | Fulfil the order. |
FAILED | Card declined, or authentication failed. | Offer another payment method. |
CANCELLED | Session 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.
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
Common errors
What you will see most often, and why.
| Error | Cause and fix |
|---|---|
401 Unauthorized | One of the two keys is missing, or they do not match. Check X-API-Key AND X-Secret-Key. |
400 — paymentMethod | CARD is not enabled on this application. Tick it in its payment methods. |
400 — amount | Amount below USD 1. Remember: the amount is in dollars, not francs. |
400 — returnUrl | returnUrl missing. It is required for a card payment. |
409 Conflict | This externalId already has an active transaction. Use a different one, or fetch the existing transaction. |
Aucun webhook reçu | Your 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.
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.
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
In local development
Going live
Once your tests pass:
- Swap your test keys for production keys.
- Check that your webhook and return URLs point to your real domain.
- Run one small real payment before opening up traffic.