Building a Production bKash Integration with Claude Code in One Afternoon
The real bKash Tokenized Checkout flow, the four production traps that kill most tutorials, and the spec-first prompt that gave me working code in 4 hours. Real code from buildwithraju.dev.
Raju H
AI Builder · Dhaka
TL;DR
Most bKash tutorials online stop at "redirect the user, here's a success page." That's a demo, not a production integration. Real bKash has four traps that will eat your money if you skip them: idempotency, callback verification, seat-hold TTL, and stale-pending cleanup. I built the integration on this site (buildwithraju.dev) in one afternoon with Claude Code by writing a single spec that named those four traps upfront. The whole src/lib/bkash.ts module is 313 lines. Here's what the spec looked like and why it worked.
Why bKash integrations are usually a mess
I've reviewed maybe a dozen bKash integrations in BD startups over the past two years. Three patterns repeat:
The "redirect and pray" pattern. Tutorial code that calls
createPayment, redirects to bKash, and assumes a 200 response means money landed. It doesn't — bKash's success callback only proves the user clicked "Confirm." The actual money state requires a separateexecutePaymentcall.The "trust the query string" pattern. Reading
?status=successfrom the callback URL and marking the row paid. Anyone with a curl command can fake this. I've seen this in production at a BD startup that shall not be named — they got hit with ~15 fake payment-marked rows in one week.The "seats forever" pattern. Pending payment rows count toward seat limits indefinitely. User starts checkout, abandons, the seat is held forever, you lose the actual paying participant. Real bKash sessions expire in ~30 minutes, but your seat-count math has to know that.
None of these are bKash's fault. The official docs are actually clear if you read them carefully. The problem is that 90% of the tutorials online are written by people who built a demo, not a production system that ever processed a refund.
The 4 production traps (the spec I wrote for Claude Code)
Before opening Claude Code, I wrote this in docs/bkash-spec.md:
# bKash Tokenized Checkout — production requirements
## Trap 1: Idempotency
`bkashPaymentId` is the idempotency key. On callback, look up by
`bkashPaymentId`; if already `paid`, return success without re-executing
or re-updating. This prevents double-charges from callback retries.
## Trap 2: Callback verification (no webhook signature)
bKash Tokenized Checkout has no webhook signature mechanism. The
`executePayment` API call IS the verification — only mark a row `paid`
when execute returns `transactionStatus: "Completed"`. Never trust the
query string alone.
Match `merchantInvoiceNumber` in execute response back to your
`invoiceId` row before updating. Don't trust query params alone.
## Trap 3: Seat-hold TTL
Pending registrations count toward `seatLimit` for 15 minutes; older
pending rows are excluded from seat-count math. After 15 min, the seat
is released to the pool.
## Trap 4: Stale-pending cleanup
Runs on every seat-count read — no cron. The `getSeatCount` query
filters `paymentStatus = 'pending' AND createdAt >= now() - 15 min`,
which means old pending rows silently stop occupying seats. The row
stays in the DB for audit; the math just ignores it.
## Three callback statuses
| Status | Action |
|----------|---------------------------------------------------------|
| success | Call executePayment → match invoice → update row to paid|
| failure | Leave row pending, show retry UI |
| cancel | Leave row pending, show retry UI |
That's the spec. Six paragraphs. Then I opened Claude Code and pasted it in with: "Implement this against the bKash Tokenized Checkout v1.2.0-beta API. The webapp is Next.js 16 with Drizzle ORM and Postgres. Module path: src/lib/bkash.ts. Idempotency key column is bkashPaymentId in the registrations table."
What Claude Code produced (and what I edited)
Four hours later, three files existed:
src/lib/bkash.ts— the API client + token caching (313 lines)src/app/api/payment/callback/route.ts— the callback handler (193 lines)src/app/bootcamp/[slug]/register/actions.ts— the create-payment Server Action
The bKash module has a singleton with idempotent token caching:
class BkashClient {
private tokenCache: { token: string; expiresAt: number } | null = null;
async getToken(): Promise<string> {
// 58-minute cache — tokens are valid for 1h but renew 2min early
// to avoid race on expiry.
if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) {
return this.tokenCache.token;
}
// ... grant_token call, cache result
}
}
Module-singleton because serverless cold starts re-fetch anyway; warm requests within an hour reuse the same token. Acceptable trade-off.
The callback handler enforces Trap 1 + 2 in one query:
// Atomic-ish update: set paid only if still pending. Prevents
// double-write if the user re-hits the success URL.
const updated = await db
.update(registrations)
.set({ paymentStatus: "paid", bkashTrxId: executeResponse.trxID })
.where(
and(
eq(registrations.invoiceId, executeResponse.merchantInvoiceNumber),
eq(registrations.paymentStatus, "pending"),
),
)
.returning();
The WHERE paymentStatus = 'pending' is the idempotency gate. A retry hits the same row but matches zero rows because it's already paid — we return success without writing again.
Trap 3 (seat-hold TTL) lives in the seat-count query, not the bKash flow at all:
const cutoff = new Date(Date.now() - SEAT_HOLD_MINUTES * 60 * 1000);
const result = await db.select({ count: sql<number>`count(*)` })
.from(registrations)
.where(
and(
eq(registrations.bootcampId, bootcampId),
or(
eq(registrations.paymentStatus, "paid"),
and(
eq(registrations.paymentStatus, "pending"),
gte(registrations.createdAt, cutoff),
),
),
),
);
That's the trick to making Trap 4 invisible: pending rows older than 15 minutes just stop matching the filter. No cron, no UPDATE, no DELETE. The seat returns to the pool automatically the next time anyone reads the count.
What I had to edit by hand
Three things Claude Code didn't get right on the first pass — all interesting because they're about the boundary between bKash and the rest of my app:
bKash service fee passthrough. bKash charges 1.5% per transaction. The first version of the code paid this out of bootcamp revenue. I added a
serviceFeeBdtcolumn to theregistrationstable, computedceil(priceBdt * 1.5 / 100)at registration time, and froze it on the row so later fee-rate changes don't break historical refund math. Claude Code hadn't been told about this; once I explained the constraint in a follow-up prompt, the schema migration and the math came out clean.Refund semantics — deposit-only. Same fee story but on the refund side. bKash also takes the fee on the refund leg. So if you refund the full amount the customer paid, you eat both fees. The spec I wrote later: "On any refund (completion or cancellation), refund only the deposit (
priceBdt); the bKash service fee was for the transaction service rendered and is non-refundable." This is now documented in the terms page so participants see it before paying.Sandbox URL switching. The original code hardcoded
https://tokenized.sandbox.bka.sh/v1.2.0-beta. Production ishttps://tokenized.pay.bka.sh/v1.2.0-beta. Same client code, different base URL. Took 30 seconds to switch via env var (BKASH_BASE_URL), but I cut myself a real test in dev withBKASH_DEV_MOCK=trueso the rest of the app can run without hitting bKash at all.
The spec-first takeaway
If I'd opened Claude Code and said "build bKash integration for my Next.js app", I'd have gotten the redirect-and-pray pattern. Probably good-looking code, probably broken in production within a week.
What made it work was writing the four traps as numbered constraints before any code existed. Claude Code didn't have to infer what production meant — I'd told it. Token caching, atomic-pending guards, seat-count math with the TTL filter — all of it came out structurally correct on the first pass because the spec said it had to.
This is what we mean by spec-driven AI. The model isn't the bottleneck. Knowing what to ask for is.
Try it yourself
The full code is open on archiom-io/bootcamp. The relevant files:
src/lib/bkash.ts— the API clientsrc/app/api/payment/callback/route.ts— the callback handlersrc/app/bootcamp/[slug]/register/actions.ts— the registration Server Action
bKash's docs:
- Tokenized Checkout API reference (official)
- Sandbox setup guide (official)
Existing community tutorials I'd recommend reading first for context:
- bKash Payment with NextJS & TypeScript (DEV Community) — the "basic version"
- bkash-pgw GitHub package — a packaged client if you'd rather not write your own
Read those first to see the shape. Then write your four-trap spec. Then let the AI write the boring parts.
Want the spec template I used? It's in the bootcamp materials for participants of the next batch, or just clone the bootcamp repo and read .claude/rules/bkash.md — same content. To scaffold your own project rules, the free AGENTS.md generator and Spec Builder are a fast start.