EthioDeploy
DocsPricingDomainsBlogToolsChangelog

Blog/Tutorials

Accept Chapa payments in Next.js and deploy it

BS
Brook SolomonPublished November 4, 2026
Share on XShare on LinkedIn
POST /v1/transaction/initialize

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?

  1. Your server saves a pending order with a unique tx_ref and sends POST https://api.chapa.co/v1/transaction/initialize with the amount, the currency and your secret key.
  2. 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.
  3. Chapa sends the customer back to your return_url, sends a GET to your callback_url and POSTs a charge.success webhook if you set one up.
  4. 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_ref must be unique. Reusing one returns 400, “Transaction reference has been used before”, so generate a new one for every attempt.
  • currency is ETB or USD.
  • If you pass phone_number, it must be 10 digits in the 09xxxxxxxx or 07xxxxxxxx format.

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.

MethodTest numberAlso enter
Card4200 0000 0000 0000 or 5400 0000 0000 0000CVV 123, expiry 12/34
telebirr, CBE Birr, COOPPay-ebirr0900123456, 0900112233, 0900881111No OTP listed
Awash Bank, Amole0900123456, 0900112233, 0900881111OTP 12345
M-Pesa0700123456, 0700112233, 0700881111None 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:

  1. Add CHAPA_SECRET_KEY, CHAPA_WEBHOOK_SECRET and APP_URL (https://<project-slug>.ethiodeploy.app, or your own domain) in the project's environment variables, and redeploy.
  2. Store orders in a database, not in memory: a redeploy replaces the container. A Postgres addon gives you DATABASE_URL.
  3. 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.
  4. 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:

  • Chapa: Accept payments
  • Chapa: Verify payments
  • Chapa: Webhooks
  • Chapa: Responses and error codes
  • Chapa: Test mode vs live mode
  • Chapa: Testing cards
  • Chapa: Testing mobile numbers
  • Chapa: Payment methods
  • Chapa: SDKs and plugins
  • Chapa: Inline.js
  • Chapa: Pricing
  • Next.js: redirect
  • Next.js: Route Handlers

Push a repo. Get a URL.

  • Deploy a Next.js app
  • Web hosting in Ethiopia
  • Database addons
  • Environment variables
Start free

Join the EthioDeploy Telegram

Release notes as they ship, plus deploy help from the team and other developers.

Join on Telegram
BS
WRITTEN BYBrook SolomonFounder, EthioDeploy

Brook Solomon founded EthioDeploy and builds its deploy pipeline, build servers and AI coding agent. The posts here come from errors seen in real deploys on the platform.

All posts by Brook →
On this page
  • How does a Chapa payment work?
  • Which keys and env vars do I need?
  • How do I start a payment?
  • How do I verify a Chapa payment?
  • What do callback_url and return_url do?
  • How do I handle Chapa webhooks?
  • How do I test Chapa payments?
  • How do I deploy it?
  • Questions
  • Sources
0% read

Keep reading

  • $7 · $7 · usage

    Hosting and pricingNov 7, 2026

    Heroku vs Render vs Railway: pricing, free tiers and databases in 2026

    For a small always-on app, Heroku Basic and Render's starter instance both cost $7 a month for 512 MB of memory, and Railway bills by usage from a $5 Hobby plan that includes $5 of usage. Render is the only one of the three with a free web tier that has no time limit, but it sleeps after 15 minutes and its free Postgres expires after 30 days. Heroku has no free tier and moved to maintenance-only engineering in February 2026. Railway is cheapest for small, mostly idle services and the hardest to predict for busy ones.

  • DEBUG = False

    Fix deploy errorsNov 7, 2026

    Django static files 404 in production

    Django only serves static files itself while DEBUG is True. Its docs call that method grossly inefficient and probably insecure, so with DEBUG = False in production every /static/ URL returns 404 and the admin loses its styling. The fix has three parts: set STATIC_ROOT, run python manage.py collectstatic during the build, and serve the collected folder, usually with WhiteNoise added to MIDDLEWARE right after SecurityMiddleware.

  • Access-Control-Allow-Origin

    Fix deploy errorsNov 7, 2026

    CORS error after deploying frontend and backend separately

    Once your frontend and API are deployed to different domains, the browser treats every API call as cross-origin and blocks the response unless the API sends an Access-Control-Allow-Origin header naming the frontend's exact origin. It worked locally because the Vite dev proxy, or a single localhost origin, hid the problem. The fix goes on the backend: allow the deployed frontend's origin, with scheme and no trailing slash, answer OPTIONS preflight requests, and never combine credentials with a * origin.

EthioDeploy

Agentic first development and deployment, started in Ethiopia, built for developers everywhere.

Telegram community
Product
PricingAI coding agentAgent mode docsDomainsCLIFree backend hostingHosting in EthiopiaChangelog
Deploy guides
Next.jsNode.jsPythonSvelteKitTanStack StartDjangoFastAPIFlaskExpressNestJSLaravelRailsGoTelegram botDiscord botLovableBoltReplitv0Google AI StudioBase44Firebase StudioClaude Code & Cursor
Compare
vs Herokuvs Vercelvs Rendervs Railwayvs Fly.iovs Netlifyvs KoyebHeroku alternativesVercel alternativesNetlify alternativesRender alternativesRailway alternativesFly.io alternativesReplit alternatives
Resources
DocsBlogFree toolsAboutSupportContactllms.txt
© 2026 EthioDeployPay by card in USD or bank transfer in ETBTermsPrivacy