Accept Chapa payments in Next.js and deploy it
THE SHORT ANSWER
To take Chapa payments in Next.js, call Chapa's initialize endpoint from the server with your secret key, redirect the customer to the checkout_url it returns, and mark the order paid only after GET /v1/transaction/verify/<tx_ref> says the status is success and the amount, currency and mode match. Chapa tells you a payment happened three ways: the customer comes back to your return_url, Chapa calls your callback_url, and it POSTs a webhook. Treat all three as a cue to verify, never as proof.
How does a Chapa payment work?
- Your server saves a pending order with a unique
tx_refand sendsPOST https://api.chapa.co/v1/transaction/initializewith the amount, the currency and your secret key. - Chapa answers with a
data.checkout_url. You redirect the customer there, and they pay with telebirr, CBE Birr, M-Pesa, a card or one of the other methods Chapa supports. - Chapa sends the customer back to your
return_url, sends a GET to yourcallback_urland POSTs acharge.successwebhook if you set one up. - Your server calls
GET https://api.chapa.co/v1/transaction/verify/<tx_ref>and marks the order paid only if the answer matches the order.
Everything with the secret key happens on the server: a Server Action, a Route Handler or a Server Component. The browser never sees the key and never decides the price.
Which keys and env vars do I need?
Copy the test secret key from the Chapa dashboard. Test keys have TEST in their prefix (CHASECK_TEST-...), and in test mode no real money moves. Put it in .env.local:
CHAPA_SECRET_KEY=CHASECK_TEST-xxxxxxxxxxxxxxxx APP_URL=http://localhost:3000
Don't give it a NEXT_PUBLIC_ prefix: that would build the key into the JavaScript every visitor downloads. Test and live transactions stay separate, too: a live key can't verify a test transaction and a test key can't verify a live one.
How do I start a payment?
A Server Action creates the reference, calls initialize and redirects to Chapa's checkout page. Look the price up on the server; anything the form sends can be edited by the customer:
// app/checkout/actions.ts
"use server";
import { randomUUID } from "node:crypto";
import { redirect } from "next/navigation";
export async function startCheckout(formData: FormData) {
const email = String(formData.get("email") ?? "");
const amount = "500"; // from your database, never from the form
const txRef = `order-${randomUUID()}`;
// Save { txRef, amount, currency: "ETB", status: "pending" } here.
const res = await fetch("https://api.chapa.co/v1/transaction/initialize", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CHAPA_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount,
currency: "ETB",
email,
tx_ref: txRef,
callback_url: `${process.env.APP_URL}/api/chapa/callback`,
return_url: `${process.env.APP_URL}/orders/${txRef}`,
customization: { title: "My Shop", description: "Order payment" },
}),
});
const json = await res.json();
if (json.status !== "success") {
throw new Error(`Chapa initialize failed: ${JSON.stringify(json.message)}`);
}
redirect(json.data.checkout_url);
}// app/checkout/page.tsx
import { startCheckout } from "./actions";
export default function CheckoutPage() {
return (
<form action={startCheckout}>
<input name="email" type="email" required />
<button type="submit">Pay 500 ETB</button>
</form>
);
}A few rules from Chapa's docs:
tx_refmust be unique. Reusing one returns 400, “Transaction reference has been used before”, so generate a new one for every attempt.currencyisETBorUSD.- If you pass
phone_number, it must be 10 digits in the09xxxxxxxxor07xxxxxxxxformat.
Call redirect outside any try block. It works by throwing an error, so Next.js's docs say to keep it out of try/catch. It accepts an absolute URL, so it can send the customer to Chapa.
How do I verify a Chapa payment?
Put verification in one function, and call it from every path that can mark an order paid. Chapa's docs say to check that the status, amount, currency, tx_ref and mode match what you expect:
// lib/chapa.ts
type Order = { txRef: string; amount: string; currency: "ETB" | "USD" };
export async function verifyChapaPayment(order: Order): Promise<boolean> {
const res = await fetch(
`https://api.chapa.co/v1/transaction/verify/${encodeURIComponent(order.txRef)}`,
{
headers: { Authorization: `Bearer ${process.env.CHAPA_SECRET_KEY}` },
cache: "no-store",
},
);
if (!res.ok) return false; // 404 means "Payment not paid yet"
const { data } = await res.json();
const expectedMode = process.env.CHAPA_SECRET_KEY?.includes("TEST") ? "test" : "live";
return (
data?.status === "success" &&
data.tx_ref === order.txRef &&
data.currency === order.currency &&
Number(data.amount) === Number(order.amount) &&
data.mode === expectedMode
);
}Number() is there on purpose: Chapa's examples show amount as a number in the verify response and as a string like "400.00" in webhooks. data.status can be success, pending or failed; only success means paid.
Marking the order paid should also be idempotent: UPDATE orders SET status = 'paid' WHERE tx_ref = $1 AND status = 'pending'. The callback, the webhook and the return page can all arrive for the same payment, and only the first should ship the goods.
What do callback_url and return_url do?
callback_url is for your server. Chapa's docs describe it as a script that should use the verify endpoint, and say it receives a GET with trx_ref, ref_id and status. Note the spelling: trx_ref, not tx_ref. The docs call it a JSON payload on a GET request, so read the query string and fall back gracefully:
// app/api/chapa/callback/route.ts
import type { NextRequest } from "next/server";
import { verifyChapaPayment } from "@/lib/chapa";
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const txRef = params.get("trx_ref") ?? params.get("tx_ref");
if (!txRef) return new Response("missing trx_ref", { status: 400 });
const order = await findOrder(txRef); // your database
if (order?.status === "pending" && (await verifyChapaPayment(order))) {
await markOrderPaid(txRef); // idempotent, see above
}
return new Response("ok");
}return_url is for the customer: Chapa redirects them there after paying. Don't trust the redirect itself. Make the page verify too, because the customer can land on it before the callback or the webhook has run:
// app/orders/[txRef]/page.tsx
import { verifyChapaPayment } from "@/lib/chapa";
export default async function OrderPage({
params,
}: {
params: Promise<{ txRef: string }>;
}) {
const { txRef } = await params;
const order = await findOrder(txRef);
if (!order) return <p>Order not found.</p>;
if (order.status === "pending" && (await verifyChapaPayment(order))) {
await markOrderPaid(txRef);
order.status = "paid";
}
return <p>{order.status === "paid" ? "Payment received." : "Waiting for payment…"}</p>;
}How do I handle Chapa webhooks?
Add a webhook URL and a secret in the Chapa dashboard, under your profile settings → Webhooks. Chapa POSTs a JSON body with an event such as charge.success, charge.failed or charge.refunded, plus the tx_ref. Without a 200 it retries every 10 minutes, up to 10 times over 72 hours.
The webhook catches the payments the other two paths miss: the customer closed the tab, or your app was down when the callback came. Verify it the same way:
// app/api/chapa/webhook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { verifyChapaPayment } from "@/lib/chapa";
function signatureOk(body: unknown, header: string | null) {
if (!header || !process.env.CHAPA_WEBHOOK_SECRET) return false;
// Chapa's Node example signs JSON.stringify of the parsed body.
const expected = createHmac("sha256", process.env.CHAPA_WEBHOOK_SECRET)
.update(JSON.stringify(body))
.digest("hex");
return expected.length === header.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
export async function POST(request: Request) {
const body = await request.json();
const sig =
request.headers.get("x-chapa-signature") ?? request.headers.get("chapa-signature");
if (!signatureOk(body, sig)) console.warn("Chapa webhook signature mismatch", body.tx_ref);
if (body.event === "charge.success" && body.tx_ref) {
const order = await findOrder(body.tx_ref);
if (order?.status === "pending" && (await verifyChapaPayment(order))) {
await markOrderPaid(body.tx_ref);
}
}
return new Response("ok");
}Why log a bad signature instead of rejecting it? Chapa's webhook page is inconsistent: it says x-chapa-signature is an HMAC SHA256 of the payload “signed using your secret key”, calls the value you set in the dashboard a “secret hash”, and a later section puts the payload signature in chapa-signature instead. Send yourself a test event, check which header and which secret match, then return 401 on a mismatch.
The order is safe either way. A forged webhook can only make your server call the verify endpoint, and verify answers from Chapa's records, not from the webhook body.
How do I test Chapa payments?
With test keys, Chapa only accepts its test cards and phone numbers, and still sends emails and webhooks. Any other number fails.
| Method | Test number | Also enter |
|---|---|---|
| Card | 4200 0000 0000 0000 or 5400 0000 0000 0000 | CVV 123, expiry 12/34 |
| telebirr, CBE Birr, COOPPay-ebirr | 0900123456, 0900112233, 0900881111 | No OTP listed |
| Awash Bank, Amole | 0900123456, 0900112233, 0900881111 | OTP 12345 |
| M-Pesa | 0700123456, 0700112233, 0700881111 | None listed |
Your local localhost callback and webhook URLs aren't reachable from Chapa, so locally you can test the checkout and the return page. Test the callback and the webhook on the deployed app.
How do I deploy it?
Any host that runs next start works. On EthioDeploy, push the repo to GitHub and create a project from it; Next.js is detected and built without config. See Deploy a Next.js app. Then:
- Add
CHAPA_SECRET_KEY,CHAPA_WEBHOOK_SECRETandAPP_URL(https://<project-slug>.ethiodeploy.app, or your own domain) in the project's environment variables, and redeploy. - Store orders in a database, not in memory: a redeploy replaces the container. A Postgres addon gives you
DATABASE_URL. - Set the webhook URL in the Chapa dashboard to
https://<project-slug>.ethiodeploy.app/api/chapa/webhook, run a test payment, and check the runtime logs for the signature line. - Switch to live keys once the test flow works end to end.
On the Free plan the app sleeps after 30 minutes without traffic. A callback or webhook that arrives while it sleeps starts it, and the request waits up to 30 seconds. If Chapa gives up on that request first, the webhook retries 10 minutes later and the return page verifies on its own, so the payment isn't lost, only delayed. A shop that takes real orders should be on Pro, which never sleeps and can be paid in Birr.
Questions
No. Chapa's SDK page lists Node.js and other packages but says some are made by its developer community, not by Chapa. The npm package chapa-nodejs is maintained by an individual developer. The REST API takes two calls, initialize and verify, so plain fetch is enough.
Chapa's payment methods page lists telebirr, CBE Birr, M-Pesa, Awash Birr, cards, PayPal, CBE bank transfer, COOP, Amole and several banks, each with its own minimum and maximum. telebirr, for example, takes 1 to 75,000 ETB per payment.
Yes. Chapa's initialize endpoint accepts ETB and USD as the currency.
Chapa's pricing page lists 2.5% per successful payment in Ethiopia, with no setup or monthly fees, and says the rate is subject to change. Check the page before you price around it.
The key and the transaction are from different modes. A live secret key can't verify a test transaction, and a test key can't verify a live one.
Not with the secret key. Chapa's inline.js checkout runs in the browser with your public key, but initialize and verify need the secret key and belong on the server.
Sources
Checked on November 4, 2026:
Push a repo. Get a URL.
Start freeJoin the EthioDeploy Telegram
Release notes as they ship, plus deploy help from the team and other developers.