--- name: chapa-payments description: Integrate the Chapa payment gateway (Ethiopia) into any backend or frontend - hosted checkout, verify, webhooks with HMAC signature checks, direct charge (telebirr, M-Pesa, CBEBirr, ebirr), split payments, refunds, transfers, test cards and test numbers. Includes working Laravel, PHP, Node.js (Express, Next.js, NestJS), Python (Django, FastAPI), HTML checkout, Inline.js and Flutter examples. Use when the user mentions Chapa, chapa.co, api.chapa.co, CHASECK / CHAPUBK keys, tx_ref, Ethiopian payments, telebirr or birr checkout, or asks to add, debug, verify or secure a Chapa integration or an admin config for Chapa. license: MIT metadata: author: alpha-mintamir version: "1.0.0" source: https://developer.chapa.co --- # Chapa Payments Chapa is a payment gateway for Ethiopia (telebirr, CBEBirr, M-Pesa, Awash, ebirr, cards, PayPal). Base URL `https://api.chapa.co/v1`. Every server call is `Authorization: Bearer `. The instructions below come from the official docs at https://developer.chapa.co and were validated against a production integration. Follow them exactly; payment bugs cost real money. ## Pick the integration | Need | Use | Reference | | --- | --- | --- | | Web or mobile app, any payment method, fastest and safest | Standard (hosted) checkout: server calls initialize, redirects to `checkout_url` | this file | | Static site, no backend for initialize | HTML checkout form posting to `/v1/hosted/pay` | [frontend.md](references/frontend.md) | | Embedded form on your page (public key only) | Inline.js `ChapaCheckout` | [frontend.md](references/frontend.md) | | Flutter app | `chapasdk` package (native or web checkout) | [flutter.md](references/flutter.md) | | Own UI for USSD / OTP wallets, POS, ERP | Direct Charge `/v1/charges?type=...` then `/v1/validate` | [advanced.md](references/advanced.md) | | Marketplace payouts to vendors | Subaccounts + `subaccounts` in initialize, or Transfers API | [advanced.md](references/advanced.md) | Default to Standard checkout unless the user asks otherwise. All of them end with the same rule: **the server verifies with `GET /v1/transaction/verify/{tx_ref}` before giving value.** ## Standard checkout flow ``` 1. Server POST /v1/transaction/initialize {amount, currency, tx_ref, callback_url, return_url, ...} -> data.checkout_url 2. Browser redirected to checkout_url, customer pays on Chapa's page 3. Chapa GET callback_url?trx_ref=..&ref_id=..&status=.. (server to server, may arrive before 4) 302 return_url (customer's browser) POST webhook URL (if enabled in dashboard) 4. Server GET /v1/transaction/verify/{tx_ref} on EVERY one of those hits accept only if data.status == "success" AND amount, currency, tx_ref match your record 5. Server marks the order paid exactly once (idempotent), then shows the result page ``` ### Initialize payload ```json { "amount": "100.00", "currency": "ETB", "tx_ref": "order-1042-8f3k2m", "callback_url": "https://example.com/payments/chapa/callback?payment_id=1042", "return_url": "https://example.com/payments/chapa/return?payment_id=1042", "email": "abebe@example.com", "first_name": "Abebe", "last_name": "Bikila", "phone_number": "0912345678", "customization": { "title": "Lomi Store", "description": "Order 1042" }, "meta": { "order_id": "1042" } } ``` Rules that cause real failures when ignored: - `amount`, `currency`, `tx_ref` are required. `currency` is `ETB` or `USD` only (Direct Charge: `ETB` only). - `tx_ref` must be unique per attempt. Chapa rejects reuse with `Transaction reference has been used before` (400). Generate a fresh one for every retry and store it on your payment record. Use only letters, digits, `-` and `_` (it becomes a URL path segment in verify). - `phone_number` is optional, but if sent it must be exactly 10 digits in `09xxxxxxxx` or `07xxxxxxxx` form. Convert `+2519...` / `2519...` to `09...` or omit the field. - `email`, `first_name`, `last_name` are optional. Only send an email that passes validation. - `customization.title` is short (16 characters is safe) and both title and description should be plain text (letters, numbers, spaces). Strip other symbols; Chapa validates these. - Send JSON with `Content-Type: application/json`. Wrong content type returns 400 `Incorrect header settings`. - Success looks like `{"message":"Hosted Link","status":"success","data":{"checkout_url":"https://checkout.chapa.co/checkout/payment/..."}}`. Treat anything else as failure and log `message`. - `callback_url` and `return_url` may be the same URL. Both are optional but you want both. - Optional `meta` keys: `hide_receipt`, `disable_phone_edit`, `custom_receipt_enabled`, `invoices` (array of `{key, value}`), `payment_reason`, plus any custom keys. ### Verify `GET /v1/transaction/verify/{tx_ref}` returns: ```json { "message": "Payment details", "status": "success", "data": { "status": "success", "amount": 100, "currency": "ETB", "tx_ref": "order-1042-8f3k2m", "reference": "6jnheVKQEmy", "charge": 3.5, "mode": "test", "method": "telebirr", "type": "API", "first_name": "...", "email": "...", "meta": null, "created_at": "..." } } ``` - `data.status` is one of `success`, `pending`, `failed`. Only `success` gives value. - A pending payment returns 404 `Payment not paid yet`. Do not mark it failed; the webhook will confirm later. - Compare `data.amount` (>= expected, tolerate 0.01), `data.currency`, `data.tx_ref`, and `data.mode` against your record. - Key/mode mismatch returns 401: `Live secret keys can't be used to verify a test transaction` (and the reverse). Use the key of the mode the payment was created in. - Never trust `status` from the callback query string or the return URL. Only trust verify. ### Callback vs return vs webhook - Callback: `GET callback_url` with `trx_ref`, `ref_id`, `status` in the query. Server to server, fires once payment completes, may race the browser redirect. - Return: the customer's browser lands on `return_url`. Run the same verify logic, then render success or "still processing". - Webhook: `POST` JSON to the URL configured in Dashboard > Settings > Webhooks. Required for mobile money that completes after the customer leaves, and for abandoned tabs. See [webhooks.md](references/webhooks.md). - Make the "mark paid" step atomic (`UPDATE ... WHERE id = ? AND is_paid = 0`) and run the success side effects only when that update changed a row. All three channels can hit at the same second. ### Webhook essentials - Body is JSON with `event` (`charge.success`, `charge.failed/cancelled`, `charge.refunded`, `charge.reversed`, `payout.success`, `payout.failed/cancelled`), `tx_ref`, `reference`, `amount`, `currency`, `status`, `payment_method`, `type`, `mode`, `meta`. - Headers: `x-chapa-signature` = HMAC SHA256 of the payload signed with your **webhook secret hash**; `chapa-signature` = HMAC SHA256 of the secret hash signed with itself. Either matching header is sufficient; missing or mismatched means reject. - Compute the payload HMAC over the raw request body. Chapa's own sample uses `JSON.stringify(req.body)`, so also accept the HMAC of the re-serialized body as a fallback. - Disable CSRF on the webhook route. Return `200` fast. Without a 200 Chapa retries every 10 minutes, up to 10 times over 72 hours, so the handler must be idempotent. - After the signature check, still call verify before giving value (Chapa says so, and it protects against replay with a forged body). - The secret hash is any random string you choose; it is not the API key. Store it as an env var and paste the same value in the dashboard. ## Configuration Environment variable names to use unless the project already has a convention: ``` CHAPA_PUBLIC_KEY=CHAPUBK_TEST-... # CHAPUBK-... in live CHAPA_SECRET_KEY=CHASECK_TEST-... # CHASECK-... in live CHAPA_WEBHOOK_SECRET= Settings > Webhooks> CHAPA_ENCRYPTION_KEY=... # only for Direct Charge OTP/card payloads (Settings > API) ``` - Test keys carry `_TEST` in the prefix. Test mode still sends webhooks and emails but only test cards and test numbers succeed. - Keys live in Dashboard > Settings > API Keys. The dashboard toggle switches which set is shown. - Never ship the secret key to a browser or mobile app. Public key only on the client (HTML checkout, Inline.js, Flutter). - When building an admin config screen (multi vendor platforms, CMS plugins), store `public_key`, `secret_key`, `webhook_secret_hash` per mode (test / live), show the callback and webhook URLs read-only so the merchant can paste them into Chapa, and list `ETB` and `USD` as the only supported currencies. ## Implementation checklist Copy and track: ``` - [ ] Env vars / admin settings for public key, secret key, webhook secret (test and live) - [ ] Payment record table has: tx_ref (unique), amount, currency, status, chapa_reference - [ ] Initialize endpoint: fresh tx_ref per attempt, phone normalised, payload validated, redirect to checkout_url - [ ] Callback + return handler: verify, compare amount/currency/tx_ref, atomic mark paid, pending stays pending - [ ] Webhook POST handler: CSRF off, signature check (both headers), verify, idempotent, 200 response - [ ] Failure path: log Chapa `message`, show retry option, never mark paid - [ ] Test in test mode with test card 4200 0000 0000 0000 or test number 0900123456, then switch keys to live - [ ] Confirm webhook URL is HTTPS and publicly reachable (use a tunnel locally) ``` ## Testing - Cards: Visa `4200 0000 0000 0000`, Mastercard `5400 0000 0000 0000`, UnionPay `6200 0000 0000 0000`, Diners `3800 0000 0000 0000` (CVV `123`), Amex `3700 0000 0000 0000` (CVV `1234`). Expiry `12/34`. - Mobile money success numbers: `0900123456`, `0900112233`, `0900881111` (Awash and Amole OTP `12345`); M-Pesa `0700123456`, `0700112233`, `0700881111`. Any other number fails in test mode. - `scripts/init_test_payment.sh` creates a checkout link; `scripts/verify_transaction.sh ` prints the verify response; `scripts/sign_webhook.py` computes both signature headers and can POST a signed sample event to your local endpoint. ## Stack guides Read only the one that matches the project: - [laravel.md](references/laravel.md): controller with initialize, callback/return, webhook, plus the official `chapa/chapa-laravel` package. - [nodejs.md](references/nodejs.md): Express with raw body capture, Next.js App Router route handlers, NestJS pointer, `chapa-nodejs` SDK. - [python.md](references/python.md): Django views and FastAPI routes, `chapa` package. - [php.md](references/php.md): plain PHP with cURL for WordPress plugins, legacy apps and cPanel hosting. - [frontend.md](references/frontend.md): HTML checkout, Inline.js, React usage, and why the frontend must never be the source of truth. - [flutter.md](references/flutter.md): `chapasdk` native and web checkout with server side verify. - [advanced.md](references/advanced.md): Direct Charge (USSD / OTP / portal), encryption, split payments and subaccounts, refunds, transfers, balance, banks, cancel. - [api-reference.md](references/api-reference.md): every endpoint, field, response and error message in one place. - [webhooks.md](references/webhooks.md): payload shapes for each event and signature verification in PHP, Node, Python, Go. ## Common mistakes to catch in review - Marking paid from the `status=success` query parameter without calling verify. - Reusing `tx_ref` on retry, then reporting "Chapa is down" when it is a 400. - Sending `+251912345678` as `phone_number`. - Webhook route behind CSRF or auth middleware, so Chapa gets 419/401 and retries for 72 hours. - Verifying a test payment with the live key after switching the dashboard toggle. - Treating `pending` as failed and cancelling an order that completes 30 seconds later. - Comparing amounts as floats without rounding, or forgetting the currency check when both ETB and USD are enabled. - Storing only one set of keys when the platform lets merchants toggle test and live.