# QuataPay integration: instructions for a coding assistant You are adding QuataPay payments to the user's website or app. Follow this document exactly. Where it disagrees with what you remember about other payment providers (Stripe, PayPal, Flutterwave and so on), this document wins. Do not invent endpoints, fields or headers: if something is not written here, say so and ask the user, or read https://quatapay.com/developer. ## 0. Ask first, then build Before writing any code, ask the user ONE short message with these questions, and wait for the answers: 1. Which language and framework is the backend (for example Next.js, Express, Django, FastAPI, Laravel, Rails, Go, Java, .NET)? Or is this a mobile app, a WordPress site, or a static site with no server? 2. Is it a normal checkout (an order with a fixed amount) or open amounts (donations, top-ups)? 3. Where do the app's secrets live today (an .env file, a secret manager, hosting dashboard variables)? Then: write the integration, the webhook route, and an automated test. Run the test. Show the user the three things they must do by hand (create a test API key, register the webhook endpoint, set the two environment variables). Do not ask for, paste or print any key. ## 1. What QuataPay is QuataPay is a wallet and payments platform for Cameroon. All money is Central African CFA francs (XAF). A merchant account takes payments from customers who pay with MTN Mobile Money (no QuataPay account needed) or from a QuataPay wallet. Money from a paid checkout lands in the merchant's QuataPay wallet. Live today: pay from a QuataPay wallet, and MTN Mobile Money (no QuataPay account needed). Coming soon, do not build for them: Orange Money, bank transfer, international cards, and payouts to MoMo or Orange Money. Currency: send "XAF". Other currencies are refused (HTTP 423) until QuataPay enables multi-currency for the account. ## 2. Which integration to use - Payment link. Use it when: You sell one thing or take open amounts and have no server (a social page, a WhatsApp shop, a static site). How: Create the link in the merchant panel under Payment links. Share https://quatapay.com/pay/{slug}. No code, no API key. - Hosted checkout through the API (recommended for any site or app with a backend). Use it when: You have a server, an order or cart, and want to know automatically when it is paid. How: Your server calls POST /gateway/payments with an API key, redirects the customer to checkout_url, and fulfils the order from a signed webhook. - Browser script (quatapay.js). Use it when: You already create payments on your server and want the hosted checkout in a popup instead of a full-page redirect. How: Your server still creates the payment. The page loads https://quatapay.com/quatapay.js and calls QuataPay.openCheckout({ slug }). Never put an API key in the page. - SDK (Node or Python). Use it when: You want typed helpers for live payments and webhook verification. The SDKs only call the live path, so test keys do not work with them: use plain HTTP while you are in the sandbox. How: Source is in the sdk/ folder of the QuataPay repository. Plain HTTP works in every language and needs no package. If the user has a backend, build "Hosted checkout through the API" (the rest of this document). If they have no backend and one product, tell them to use a payment link and stop. If it is a mobile app (Flutter, Kotlin, Swift, React Native), the app must call the merchant's OWN backend, and that backend calls QuataPay; the app only opens the returned checkout_url in the browser or a web view. A mobile app must never contain a QuataPay key. ## 3. Facts you can rely on - Base URL (test and live): https://api.quatapay.com/api/v1 - Auth header: Authorization: Bearer . Keys start with qpay_test_ (test) or qpay_live_ (live). - Test keys MUST use the sandbox paths (/gateway/sandbox/payments); live keys MUST use the live paths (/gateway/payments). The wrong pairing answers 403. Choose the path from the key prefix in code so going live is only an environment-variable change. - Every response is JSON: { "success": true, "data": ..., "error": null } or { "success": false, "data": null, "error": "", "code": "", "details": {...} }. - Amounts are integers in XAF. XAF has no minor unit, so 5000 means 5000 francs. Never send a float, never a string, never multiply by 100. Allowed range 1 to 100,000,000,000. Compute every amount on your server from your own price data, never trust an amount sent by the browser. - Request bodies reject unknown fields: a misspelled or invented field is HTTP 422, not ignored. - Rate limit: 60 requests per 60 seconds per API key and endpoint. Over it: HTTP 429 with a Retry-After header (seconds). - Idempotency: send an Idempotency-Key header on payment creation (up to 255 characters). The same key with the same body inside 24 hours returns the first response again (with a Idempotency-Replayed: true header) instead of creating a second payment. The same key with a different body is HTTP 409. - Every response carries X-Request-ID. Log it with every error; support asks for it. ## 4. Secrets and keys - The API key lives in the environment variable QUATAPAY_API_KEY, read on the SERVER only. The webhook signing secret lives in QUATAPAY_WEBHOOK_SECRET. - Never write a key into source code, a test fixture, a README, a client-side bundle, a mobile app, a URL or a log line. In Next.js and similar frameworks, never use a NEXT_PUBLIC_, VITE_, REACT_APP_ or EXPO_PUBLIC_ prefix for either value. - Add .env to .gitignore if it is missing. Commit a .env.example that contains the variable names with empty values. - Tell the user where to get them: the merchant panel at https://quatapay.com/merchant/developer/keys. API key: Developer, API keys, Create key (choose Test first). The secret is shown ONCE: copy it into QUATAPAY_API_KEY straight away. Webhook secret: Developer, Webhooks, add endpoint; it is also shown once. - If a key ever appears in a chat, a commit or a log, tell the user to revoke it in the panel and create a new one. ## 5. Create the payment (server side) POST https://api.quatapay.com/api/v1/gateway/sandbox/payments (test key) or POST https://api.quatapay.com/api/v1/gateway/payments (live key). Response: HTTP 201. Body fields (all optional except amount and currency): - amount (integer, required), currency ("XAF", required) - description (text shown to the customer, max 2000 characters) - customer_reference: YOUR id for this order or customer (max 120 characters). It comes back in webhooks. Use your order id. - order_reference: your order number shown on the checkout as "Order #" (1 to 64 characters: letters, digits, space and _ # . / -) - return_url, cancel_url, failure_url: where the customer's browser goes after paying, cancelling, or failing/expiring. https only (http only for localhost). No credentials in the URL. - metadata: an object that comes back in webhooks. metadata.line_items (up to 20 { "label", "amount" } rows that must add up to amount) is shown on the checkout. - expires_in_minutes: 1 to 1440, default 60. - allowed_methods: ["wallet"], ["momo"] or both. Leave it out to offer every live method. - webhook_url: also send this payment's events to this URL. Prefer a registered webhook endpoint (section 7) and leave this out. Success response: data.payment (id, slug, status "requested", amount, currency, mode, expires_at, ...) and data.checkout_url. Store data.payment.id with your order (this is the id that GET uses) and redirect the customer's browser to data.checkout_url. Create the payment when the customer clicks "Pay", not when the page loads. Save the Idempotency-Key with the order: generate ONE per checkout attempt, reuse it when retrying that same attempt after a timeout or a 5xx, and generate a new one for a new attempt (for example after the first payment expired). ## 6. Redirect and return handling The hosted checkout page is run by QuataPay. The customer picks wallet or Mobile Money, sees the total and any fee, and confirms. You build no payment form and handle no card or phone numbers. When it ends, the browser is sent to return_url (paid), cancel_url (cancelled) or failure_url (failed or expired) exactly as you wrote it. QuataPay adds NO query parameters, so put your own order id in the URL, for example https://your-site.example/pay/return?order=. The return page only tells the customer "we are confirming your payment" and shows what your database says. It must NOT mark the order paid: anyone can open the return URL. The order is paid only when the signed webhook arrives (section 7), or when your server calls GET and sees status "succeeded" (section 8). If the webhook has not arrived yet, show "waiting for confirmation" and let the page re-check your own database every few seconds. ## 7. Webhook receiver (this is how an order becomes paid) Register an endpoint in the merchant panel (Developer, Webhooks). It must be a public https URL (http, localhost and private addresses are refused: for local development use a tunnel such as cloudflared or ngrok). Leave the event list empty to receive everything. Each delivery is a POST with a JSON body { "event": "", "data": { ... } } (compact JSON, sorted keys) and these headers: - X-QuataPay-Timestamp: Unix seconds when this attempt was signed - X-QuataPay-Signature: "sha256=" followed by lowercase hex HMAC-SHA256 Verify EVERY request before reading it: 1. signed_payload = timestamp + "." + RAW request body (the bytes exactly as received, before any JSON parsing or re-serialising). 2. expected = "sha256=" + hex(HMAC_SHA256(QUATAPAY_WEBHOOK_SECRET, signed_payload)). 3. Compare with a constant-time function (timingSafeEqual, hmac.compare_digest, hash_equals, hmac.Equal, MessageDigest.isEqual, FixedTimeEquals). Never ==. 4. Reject if the timestamp is more than 300 seconds from your server clock (replay protection). 5. On any failed check answer HTTP 400 and do nothing else. Raw body is the usual bug: in Express put express.raw() on this route before any express.json(); in FastAPI use await request.body(); in Django request.body; in Laravel $request->getContent(); in Rails request.raw_post. Test vector (use it in your unit test): secret "test_secret_do_not_use", timestamp "1791370800", body {"data":{"amount":5000,"id":"pay-1"},"event":"gateway.payment.succeeded"} gives the header value sha256=1c3a8d850ee6a783931573bb1066171269e25dcf1a8d9709ecb6bac778e3b121. (That timestamp is old, so a correct verifier rejects it as stale unless the test passes a fixed clock.) Events: gateway.payment.succeeded, gateway.payment.failed, gateway.payment.cancelled, gateway.payment.expired, gateway.payment.escrowed, gateway.payment.released. Fulfil the order on gateway.payment.succeeded only. gateway.payment.failed, .cancelled and .expired close the attempt (let the customer try again). A payment can still end up succeeded AFTER expired (a Mobile Money approval confirmed late): the money is in the merchant wallet, so handle succeeded for an order you had already marked expired. data (same shape for every gateway event): id and payment_id (the payment id, the stable identifier), slug, mode ("test" or "live"), amount, currency, status, customer_reference, metadata, payment_method ("wallet", "momo" or null), transaction_id, completed_at, created_at, plus failure_reason on failed, cancel_reason on cancelled, and "simulated": true on events from the sandbox simulate endpoint. Delivery: answer any 2xx within 5 seconds. Otherwise QuataPay retries (5 attempts in all: 1 minute, 5 minutes, 30 minutes, 2 hours apart), only for events younger than 24 hours, then stops. The same event can arrive more than once and out of order. Idempotent handler, required: in one database transaction insert (data.id, event) into a table with a UNIQUE constraint on that pair; if the insert hits the constraint, you already handled it: answer 200 and do nothing. Then check that data.amount equals the amount you stored for that order and data.customer_reference matches your order id; only then mark it paid. Answer 200 quickly and do slow work (emails, shipping) after. ## 8. Check status yourself GET https://api.quatapay.com/api/v1/gateway/sandbox/payments/{payment_id} (test key) or GET https://api.quatapay.com/api/v1/gateway/payments/{payment_id} (live key), with payment_id = data.payment.id from creation (not the slug). The response data is the payment itself: id, slug, mode, status, amount, currency, completed_at, completed_transaction_id, failure_reason, payment_method, allowed_methods, expires_at, and metadata under the name metadata_json (note: you SEND "metadata", you READ "metadata_json"). A payment of another merchant or the other mode is HTTP 404. status is one of: requested, processing, succeeded, failed, cancelled, expired. Terminal: succeeded, failed, cancelled, expired. (Exception: a Mobile Money payment confirmed after expiry turns succeeded.) There is no "pending" or "refunded" status. Use this for the return page and as a safety net (for example a job that re-checks orders still unpaid after 15 minutes). The list endpoint is GET https://api.quatapay.com/api/v1/gateway/payments?status=succeeded&limit=50 (limit 1 to 200; sandbox: /gateway/sandbox/payments). Refunds are not available to API keys today; the merchant makes them in the merchant panel. ## 9. What the customer pays (the Mobile Money fee) The amount you send is the amount of the order. When the customer pays by Mobile Money, QuataPay adds a payer fee on top (currently 1.3%, an admin setting that can change) and shows it on the checkout BEFORE they confirm. Your amount does not change and you must not add the fee yourself. Paying from a QuataPay wallet adds no such fee unless QuataPay has switched a wallet fee on. If you want to show the cost in your own UI, read it; never hard-code 1.3%. GET https://api.quatapay.com/api/v1/gateway/checkout/{slug} needs no key and returns data.methods[], each with an optional fee_note: { "kind": "zero" | "processing" | "government" | "fee", "bps": , "flat": , "amount": }, plus data.momo_payer_fee, data.momo_payer_fee_bps and data.momo_total for an MTN payment. Show it as information only. The merchant's own processing fee is set per merchant account: point the user to https://quatapay.com/pricing. ## 10. Errors and retries - 201 created. 4xx means fix the request, do not retry blindly. 5xx or a network timeout means retry with the SAME Idempotency-Key, with exponential backoff and jitter, at most 5 times. 429: wait for Retry-After seconds. - 401 invalid or missing API key. 403 wrong key mode for the path (test key on a live path or the reverse), or the merchant account is suspended. 404 payment not found. 409 same Idempotency-Key with a different body, or a state conflict. 422 validation failed (code VALIDATION_ERROR, details lists the fields). 423 currency or account not enabled. 429 rate limit. 503 the developer API is paused: retry later. - "code" is "VALIDATION_ERROR" for 422 and "HTTP_" (for example HTTP_401, HTTP_409) for the rest. Branch on the HTTP status first. "error" is a sentence meant for logs; show the customer your own friendly message, never the raw error. - Set a 5 second connect timeout and a 30 second read timeout. If creation times out, retry with the same Idempotency-Key: you will get the original payment back, never a second one. - Log status, code, error and the X-Request-ID header. Never log the API key, the Authorization header or the webhook secret. ## 11. Test in the sandbox before anything live 1. In the merchant panel create a TEST API key and a webhook endpoint (use a tunnel URL while developing). Put them in QUATAPAY_API_KEY and QUATAPAY_WEBHOOK_SECRET. 2. Create a payment with the test key (path /gateway/sandbox/payments). It returns a checkout_url, but a test-mode checkout CANNOT be paid in the browser (Mobile Money and wallet payments are refused for test payments). Finish it from your server or terminal instead: POST https://api.quatapay.com/api/v1/gateway/sandbox/payments/{slug}/simulate with the test key and body { "outcome": "succeeded" } or { "outcome": "failed", "failure_reason": "INSUFFICIENT_FUNDS" }. Use data.payment.slug from creation as {slug}. POST https://api.quatapay.com/api/v1/gateway/sandbox/payments/{slug}/cancel cancels a test payment. 3. The simulate call sends the same gateway.payment.* webhooks as live, signed the same way, with "simulated": true in data. Your handler must pass for succeeded, failed, a replayed duplicate, a bad signature (400) and a stale timestamp (400). 4. There are no magic customer_reference values in the API. Do not rely on any such trick. 5. The test must be automated: unit-test the signature function with the test vector, and test the handler for duplicate delivery (the order is fulfilled once). ## 12. Go-live checklist - A live API key exists (merchant panel, Developer, API keys, mode Live; shown once) and is set ONLY in the production environment as QUATAPAY_API_KEY. The same code then uses the live paths because of the key prefix. - The merchant account is in good standing and cleared by QuataPay to take live payments (finish any verification the merchant panel asks for). A suspended or banned merchant's keys are refused with 403. - A webhook endpoint with the production https URL is registered and its secret is in the production QUATAPAY_WEBHOOK_SECRET. - return_url, cancel_url and failure_url point at the production domain over https. - Server-side amount calculation, the UNIQUE (data.id, event) idempotency table and the 5 second answer time are in place. - No key, secret or test URL is in git history, client code, logs or error pages. Run a secret scan if the user has one. - Make one small real payment (for example 100 XAF) end to end and confirm the order flips to paid exactly once. - Pay with Mobile Money (MTN) and with the wallet if both should be offered. Tell the user honestly: Orange Money, bank transfer, international cards and payouts to MoMo are coming soon and not usable yet. ## 13. Starting point per language Plain HTTP, no QuataPay package. These snippets pick the sandbox or live path from the key prefix and read secrets from the environment. Adapt them to the user's framework; keep the behaviour. ### Node.js / TypeScript Create a payment: ```ts import { randomUUID } from "node:crypto"; const BASE = "https://api.quatapay.com/api/v1"; // Server-side only. idempotencyKey: make ONE per checkout attempt, save it with the order, // and reuse it when you retry the same attempt. A new attempt gets a new key. export async function createPayment(order: { id: string; amountXaf: number }, idempotencyKey = randomUUID()) { const key = process.env.QUATAPAY_API_KEY!; const path = key.startsWith("qpay_test_") ? "/gateway/sandbox/payments" : "/gateway/payments"; const res = await fetch(BASE + path, { method: "POST", headers: { Authorization: "Bearer " + key, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ amount: order.amountXaf, // integer XAF, never a float currency: "XAF", customer_reference: order.id, description: "Order " + order.id, return_url: "https://your-site.example/pay/return?order=" + order.id, cancel_url: "https://your-site.example/pay/cancel?order=" + order.id, }), signal: AbortSignal.timeout(30_000), }); const json = await res.json(); if (!res.ok) throw new Error("QuataPay " + res.status + " " + json.code + ": " + json.error); return { checkoutUrl: json.data.checkout_url as string, paymentId: json.data.payment.id as string }; } ``` Verify a webhook: ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export function validSignature(secret: string, rawBody: Buffer | string, timestamp?: string | null, signature?: string | null) { if (!timestamp || !/^\d+$/.test(timestamp) || !signature) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = Buffer.from( "sha256=" + createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest("hex"), ); const given = Buffer.from(signature); return expected.length === given.length && timingSafeEqual(expected, given); } // Express: app.post("/quatapay/webhook", express.raw({ type: "application/json" }), (req, res) => { // if (!validSignature(process.env.QUATAPAY_WEBHOOK_SECRET!, req.body, req.get("X-QuataPay-Timestamp"), req.get("X-QuataPay-Signature"))) // return res.status(400).send("invalid signature"); // const { event, data } = JSON.parse(req.body.toString("utf8")); // // 1. insert (data.id, event) into a table with a UNIQUE constraint; if it already exists, just answer 200 // // 2. answer 200 fast, then fulfil the order from your own database state // res.sendStatus(200); // }); ``` ### Python Create a payment: ```python import os import uuid import requests BASE = "https://api.quatapay.com/api/v1" def create_payment(order_id: str, amount_xaf: int, idempotency_key: str | None = None): """Server-side only. Make ONE idempotency key per checkout attempt and reuse it on retries.""" key = os.environ["QUATAPAY_API_KEY"] path = "/gateway/sandbox/payments" if key.startswith("qpay_test_") else "/gateway/payments" resp = requests.post( BASE + path, headers={ "Authorization": "Bearer " + key, "Idempotency-Key": idempotency_key or str(uuid.uuid4()), }, json={ "amount": amount_xaf, # integer XAF, never a float "currency": "XAF", "customer_reference": order_id, "description": "Order " + order_id, "return_url": "https://your-site.example/pay/return?order=" + order_id, "cancel_url": "https://your-site.example/pay/cancel?order=" + order_id, }, timeout=(5, 30), ) body = resp.json() if not resp.ok: raise RuntimeError(f"QuataPay {resp.status_code} {body.get('code')}: {body.get('error')}") return body["data"]["checkout_url"], body["data"]["payment"]["id"] ``` Verify a webhook: ```python import hashlib import hmac import time def valid_signature(secret: str, raw_body: bytes, timestamp: str | None, signature: str | None) -> bool: if not timestamp or not (timestamp.isascii() and timestamp.isdigit()) or not signature: return False if abs(time.time() - int(timestamp)) > 300: return False expected = "sha256=" + hmac.new( secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) # Raw body: Flask request.get_data(), Django request.body, FastAPI await request.body(). # Headers: X-QuataPay-Timestamp and X-QuataPay-Signature. # Idempotency: insert (data["id"], event) under a UNIQUE constraint; a duplicate means "already handled", answer 200. ``` ### PHP Create a payment: ```php true, CURLOPT_POST => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $key, 'Content-Type: application/json', 'Idempotency-Key: ' . $idempotencyKey, // one per checkout attempt, reused on retries ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => $amountXaf, // integer XAF 'currency' => 'XAF', 'customer_reference' => $orderId, 'return_url' => 'https://your-site.example/pay/return?order=' . $orderId, 'cancel_url' => 'https://your-site.example/pay/cancel?order=' . $orderId, ]), ]); $body = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($status < 200 || $status >= 300) { throw new RuntimeException("QuataPay $status " . ($body['code'] ?? '') . ': ' . ($body['error'] ?? '')); } return [$body['data']['checkout_url'], $body['data']['payment']['id']]; } ``` Verify a webhook: ```php 300) return false; $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $signature); } $raw = file_get_contents('php://input'); $ok = quatapayValidSignature( getenv('QUATAPAY_WEBHOOK_SECRET'), $raw, $_SERVER['HTTP_X_QUATAPAY_TIMESTAMP'] ?? null, $_SERVER['HTTP_X_QUATAPAY_SIGNATURE'] ?? null, ); if (!$ok) { http_response_code(400); exit; } // Then: insert (data.id, event) under a UNIQUE constraint; a duplicate means already handled, answer 200. ``` ### Ruby Create a payment: ```ruby require "json" require "net/http" BASE = "https://api.quatapay.com/api/v1" def quatapay_create_payment(order_id:, amount_xaf:, idempotency_key:) key = ENV.fetch("QUATAPAY_API_KEY") path = key.start_with?("qpay_test_") ? "/gateway/sandbox/payments" : "/gateway/payments" uri = URI(BASE + path) req = Net::HTTP::Post.new(uri) req["Authorization"] = "Bearer " + key req["Content-Type"] = "application/json" req["Idempotency-Key"] = idempotency_key # one per checkout attempt, reused on retries req.body = JSON.generate( amount: amount_xaf, # integer XAF currency: "XAF", customer_reference: order_id.to_s, return_url: "https://your-site.example/pay/return?order=" + order_id.to_s, cancel_url: "https://your-site.example/pay/cancel?order=" + order_id.to_s ) res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 30) { |h| h.request(req) } body = JSON.parse(res.body) raise "QuataPay #{res.code} #{body['code']}: #{body['error']}" unless res.is_a?(Net::HTTPSuccess) [body.dig("data", "checkout_url"), body.dig("data", "payment", "id")] end ``` Verify a webhook: ```ruby require "openssl" def quatapay_secure_equal?(a, b) return false unless a.bytesize == b.bytesize diff = 0 a.bytes.zip(b.bytes) { |x, y| diff |= x ^ y } diff.zero? end def quatapay_valid_signature?(secret, raw_body, timestamp, signature) return false unless timestamp.to_s.match?(/\A\d+\z/) && signature return false if (Time.now.to_i - timestamp.to_i).abs > 300 expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}") quatapay_secure_equal?(expected, signature) end # Rails: raw_body = request.raw_post, headers request.headers["X-QuataPay-Timestamp"] / ["X-QuataPay-Signature"], # skip_forgery_protection on the webhook action. Dedupe on (data["id"], event) with a unique index. ``` ### Go Create a payment: ```go package quatapay import ( "bytes" "encoding/json" "fmt" "net/http" "os" "strings" "time" ) const base = "https://api.quatapay.com/api/v1" type Payment struct{ CheckoutURL, PaymentID string } // idempotencyKey: one per checkout attempt, reused on retries of that attempt. func CreatePayment(orderID string, amountXAF int64, idempotencyKey string) (*Payment, error) { key := os.Getenv("QUATAPAY_API_KEY") path := "/gateway/payments" if strings.HasPrefix(key, "qpay_test_") { path = "/gateway/sandbox/payments" } body, _ := json.Marshal(map[string]any{ "amount": amountXAF, // integer XAF "currency": "XAF", "customer_reference": orderID, "return_url": "https://your-site.example/pay/return?order=" + orderID, "cancel_url": "https://your-site.example/pay/cancel?order=" + orderID, }) req, err := http.NewRequest("POST", base+path, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", idempotencyKey) res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return nil, err } defer res.Body.Close() var out map[string]any if err := json.NewDecoder(res.Body).Decode(&out); err != nil { return nil, err } data, _ := out["data"].(map[string]any) pay, _ := data["payment"].(map[string]any) if res.StatusCode < 200 || res.StatusCode > 299 { msg, _ := out["error"].(string) code, _ := out["code"].(string) return nil, fmt.Errorf("quatapay %d %s: %s", res.StatusCode, code, msg) } url, _ := data["checkout_url"].(string) id, _ := pay["id"].(string) return &Payment{CheckoutURL: url, PaymentID: id}, nil } ``` Verify a webhook: ```go package quatapay import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "time" ) func ValidSignature(secret string, rawBody []byte, timestamp, signature string) bool { ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || timestamp == "" || signature == "" { return false } if d := time.Now().Unix() - ts; d > 300 || d < -300 { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(rawBody) // the bytes exactly as received: io.ReadAll(r.Body) before any decoding expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature)) } ``` ### Java Create a payment: ```java // Java 11+. Uses Jackson (com.fasterxml.jackson.databind) to build and read JSON. import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.Map; public final class QuataPay { private static final String BASE = "https://api.quatapay.com/api/v1"; private static final ObjectMapper JSON = new ObjectMapper(); private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); /** Returns checkout_url. idempotencyKey: one per checkout attempt, reused on retries. */ public static String createPayment(String orderId, long amountXaf, String idempotencyKey) throws Exception { String key = System.getenv("QUATAPAY_API_KEY"); String path = key.startsWith("qpay_test_") ? "/gateway/sandbox/payments" : "/gateway/payments"; String body = JSON.writeValueAsString(Map.of( "amount", amountXaf, // integer XAF "currency", "XAF", "customer_reference", orderId, "return_url", "https://your-site.example/pay/return?order=" + orderId, "cancel_url", "https://your-site.example/pay/cancel?order=" + orderId)); HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path)) .timeout(Duration.ofSeconds(30)) .header("Authorization", "Bearer " + key) .header("Content-Type", "application/json") .header("Idempotency-Key", idempotencyKey) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse res = HTTP.send(req, HttpResponse.BodyHandlers.ofString()); JsonNode json = JSON.readTree(res.body()); if (res.statusCode() < 200 || res.statusCode() > 299) { throw new IllegalStateException("QuataPay " + res.statusCode() + " " + json.path("code").asText() + ": " + json.path("error").asText()); } return json.path("data").path("checkout_url").asText(); } } ``` Verify a webhook: ```java import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.util.HexFormat; public final class QuataPayWebhook { public static boolean validSignature(String secret, byte[] rawBody, String timestamp, String signature) throws Exception { if (timestamp == null || !timestamp.matches("\\d+") || signature == null) return false; if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) > 300) return false; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); mac.update(rawBody); // the bytes exactly as received String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal()); return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8)); } } ``` ### C# / .NET Create a payment: ```csharp using System.Net.Http.Json; using System.Text.Json; public static class QuataPay { static readonly HttpClient Http = new() { BaseAddress = new Uri("https://api.quatapay.com/api/v1/"), Timeout = TimeSpan.FromSeconds(30), }; // idempotencyKey: one per checkout attempt, reused on retries of that attempt. public static async Task<(string CheckoutUrl, string PaymentId)> CreatePaymentAsync( string orderId, long amountXaf, string idempotencyKey) { var key = Environment.GetEnvironmentVariable("QUATAPAY_API_KEY")!; var path = key.StartsWith("qpay_test_") ? "gateway/sandbox/payments" : "gateway/payments"; using var req = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(new { amount = amountXaf, // integer XAF currency = "XAF", customer_reference = orderId, return_url = "https://your-site.example/pay/return?order=" + orderId, cancel_url = "https://your-site.example/pay/cancel?order=" + orderId, }), }; req.Headers.Add("Authorization", "Bearer " + key); req.Headers.Add("Idempotency-Key", idempotencyKey); using var res = await Http.SendAsync(req); using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync()); if (!res.IsSuccessStatusCode) throw new InvalidOperationException("QuataPay " + (int)res.StatusCode + ": " + doc.RootElement.GetProperty("error").GetString()); var data = doc.RootElement.GetProperty("data"); return (data.GetProperty("checkout_url").GetString()!, data.GetProperty("payment").GetProperty("id").GetString()!); } } ``` Verify a webhook: ```csharp using System.Security.Cryptography; using System.Text; public static class QuataPayWebhook { public static bool ValidSignature(string secret, byte[] rawBody, string? timestamp, string? signature) { if (string.IsNullOrEmpty(timestamp) || !timestamp.All(char.IsAsciiDigit) || signature is null) return false; if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300) return false; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var signed = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray(); // raw bytes, as received var expected = "sha256=" + Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)); } } ``` ### curl (any other language) Create a payment: ```bash # Sandbox (test key). For a live key use /gateway/payments instead of /gateway/sandbox/payments. curl -sS -X POST "https://api.quatapay.com/api/v1/gateway/sandbox/payments" \ -H "Authorization: Bearer $QUATAPAY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"amount":5000,"currency":"XAF","customer_reference":"order_1042","return_url":"https://your-site.example/pay/return?order=order_1042"}' # Finish a TEST payment without a browser (test keys only): curl -sS -X POST "https://api.quatapay.com/api/v1/gateway/sandbox/payments/SLUG/simulate" \ -H "Authorization: Bearer $QUATAPAY_API_KEY" -H "Content-Type: application/json" \ -d '{"outcome":"succeeded"}' ``` Verify a webhook: ```bash # Check a signature by hand (timestamp, body and secret from a delivery you saved): printf '%s.%s' "$TIMESTAMP" "$RAW_BODY" | openssl dgst -sha256 -hmac "$QUATAPAY_WEBHOOK_SECRET" -hex # The header is "sha256=" followed by that hex digest. ``` ### Kotlin, Swift, Flutter, React Native (mobile apps) Do not call QuataPay from the app. Build the backend piece above in the merchant's server language; the app calls that backend (for example POST /api/orders//pay), receives checkout_url, and opens it with the system browser or an in-app browser. When the customer returns, the app asks the merchant's backend for the order status. A static, server-less app should use a payment link. ### Any other language Use the curl example as the contract: one JSON POST with an Authorization header and an Idempotency-Key header, one HMAC-SHA256 check on the raw webhook body. ## 14. Framework notes - Next.js (App Router): Create the payment in a Route Handler (app/api/pay/route.ts) or a Server Action; never in a Client Component. Webhook: app/api/quatapay/webhook/route.ts, read the body with `await request.text()` and verify before JSON.parse. Export `runtime = "nodejs"` for the webhook route (it uses node:crypto). Do not use NEXT_PUBLIC_ for the API key or the webhook secret. - Express: Mount the webhook with `express.raw({ type: "application/json" })` on that route only and put it BEFORE any global `express.json()`, so the body stays raw bytes. - Django: Webhook view: use `request.body` (bytes), decorate with `@csrf_exempt` and `@require_POST`, return 400 on a bad signature. Read the key with `os.environ`, never settings committed to git. - FastAPI: Webhook route: `raw = await request.body()` then verify, then `json.loads(raw)`. Do not declare a Pydantic body parameter on this route, it would consume and re-serialise the body. - Laravel: Webhook route: use `$request->getContent()` for the raw body, exclude the route from CSRF (`VerifyCsrfToken::$except` or `->withoutMiddleware`), compare with `hash_equals`. Keep keys in `.env` and read them through `config()`. - Ruby on Rails: Webhook controller: `request.raw_post` for the body, `skip_forgery_protection`, compare with `ActiveSupport::SecurityUtils.secure_compare`. Keep keys in credentials or ENV. - WordPress / WooCommerce: QuataPay does not publish a WordPress or WooCommerce plugin. Say so to the user. Options: (1) a payment link pasted into a button or page, no code; (2) a small custom plugin or a theme `functions.php` snippet that follows this prompt and uses `wp_remote_post` plus a REST route for the webhook (`register_rest_route`, raw body from `$request->get_body()`); for a WooCommerce shop that means a custom payment gateway class, which is real work: confirm the user wants it before starting. ## 15. Do not - Do not put an API key or webhook secret in client-side code, a mobile app, a URL, a log or git. - Do not mark an order paid from the return page, from the redirect, or from an amount sent by the browser. - Do not parse the webhook body before verifying its signature, and do not verify against re-serialised JSON. - Do not use floats for money, multiply by 100, or add the Mobile Money fee to the amount. - Do not invent endpoints, fields, event names, statuses or error codes. There is no "pending" status, no refund endpoint for API keys, no Orange Money, bank or card payment yet, and no QuataPay WordPress plugin. - Do not use the SDKs in the sandbox: they only call the live path, so a test key is refused with 403. Use plain HTTP while testing. - Do not retry a 4xx. Do retry 5xx, timeouts and 429 with the same Idempotency-Key. ## 16. Definition of done Deliver: (1) a function or service that creates the payment, (2) the route that redirects the customer, (3) the return and cancel pages, (4) the webhook route with signature verification and the UNIQUE (data.id, event) idempotency, (5) .env.example with QUATAPAY_API_KEY and QUATAPAY_WEBHOOK_SECRET, (6) tests: signature (with the test vector), duplicate delivery, bad signature, stale timestamp, (7) a short README note listing the manual steps for the user. Then run the tests and report the output honestly. Docs: https://quatapay.com/developer . This prompt: https://quatapay.com/developer/master-prompt.txt