按源仓库内容呈现,保留标题、案例、代码、表格、链接以及原文引用的演示图片。
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 <SECRET_KEY>. 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 |
| Embedded form on your page (public key only) | Inline.js ChapaCheckout | frontend.md |
| Flutter app | chapasdk package (native or web checkout) | flutter.md |
| Own UI for USSD / OTP wallets, POS, ERP | Direct Charge /v1/charges?type=... then /v1/validate | advanced.md |
| Marketplace payouts to vendors | Subaccounts + subaccounts in initialize, or Transfers API | 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 pageInitialize payload
{
"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_refare required.currencyisETBorUSDonly (Direct Charge:ETBonly).tx_refmust be unique per attempt. Chapa rejects reuse withTransaction 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_numberis optional, but if sent it must be exactly 10 digits in09xxxxxxxxor07xxxxxxxxform. Convert+2519.../2519...to09...or omit the field.email,first_name,last_nameare optional. Only send an email that passes validation.customization.titleis 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 400Incorrect 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 logmessage. callback_urlandreturn_urlmay be the same URL. Both are optional but you want both.- Optional
metakeys: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:
{ "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.statusis one ofsuccess,pending,failed. Onlysuccessgives 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, anddata.modeagainst 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
statusfrom the callback query string or the return URL. Only trust verify.
Callback vs return vs webhook
- Callback:
GET callback_urlwithtrx_ref,ref_id,statusin 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:
POSTJSON 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. - 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
200fast. 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=<random string you also paste in Dashboard > Settings > Webhooks>
CHAPA_ENCRYPTION_KEY=... # only for Direct Charge OTP/card payloads (Settings > API)- Test keys carry
_TESTin 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_hashper mode (test / live), show the callback and webhook URLs read-only so the merchant can paste them into Chapa, and listETBandUSDas 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, Mastercard5400 0000 0000 0000, UnionPay6200 0000 0000 0000, Diners3800 0000 0000 0000(CVV123), Amex3700 0000 0000 0000(CVV1234). Expiry12/34. - Mobile money success numbers:
0900123456,0900112233,0900881111(Awash and Amole OTP12345); M-Pesa0700123456,0700112233,0700881111. Any other number fails in test mode. scripts/init_test_payment.shcreates a checkout link;scripts/verify_transaction.sh <tx_ref>prints the verify response;scripts/sign_webhook.pycomputes 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: controller with initialize, callback/return, webhook, plus the official
chapa/chapa-laravelpackage. - nodejs.md: Express with raw body capture, Next.js App Router route handlers, NestJS pointer,
chapa-nodejsSDK. - python.md: Django views and FastAPI routes,
chapapackage. - php.md: plain PHP with cURL for WordPress plugins, legacy apps and cPanel hosting.
- frontend.md: HTML checkout, Inline.js, React usage, and why the frontend must never be the source of truth.
- flutter.md:
chapasdknative and web checkout with server side verify. - advanced.md: Direct Charge (USSD / OTP / portal), encryption, split payments and subaccounts, refunds, transfers, balance, banks, cancel.
- api-reference.md: every endpoint, field, response and error message in one place.
- 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=successquery parameter without calling verify. - Reusing
tx_refon retry, then reporting "Chapa is down" when it is a 400. - Sending
+251912345678asphone_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
pendingas 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.
