Every provider speaks a different language.
APIs, signature schemes, status codes, and webhook formats differ across Midtrans, Xendit, Duitku, Tripay, Mayar, and Pakasir. Integrating one takes days. Integrating a second one starts over.
BetterPay is an open-source TypeScript framework for Indonesian payment gateways, secure webhooks, subscriptions, invoices, entitlements, usage, billing cycles, and provider fallback. Your routes, your database, your server.
$ pnpm add @betterpay/core @betterpay/billing @betterpay/midtrans Composable, plugin-based, and built for how Indonesian payments actually work.
await pay.createTransaction({ amount: 199_000, currency: "IDR" }) The problem
It starts as one payment link. A few sprints later it is subscriptions, invoices, usage limits, webhook retries, and access control. That layer deserves a framework.
APIs, signature schemes, status codes, and webhook formats differ across Midtrans, Xendit, Duitku, Tripay, Mayar, and Pakasir. Integrating one takes days. Integrating a second one starts over.
Callbacks retry, arrive twice, arrive late, or arrive forged. Without verification, replay protection, and state validation, a webhook can corrupt the exact record it was meant to settle.
Virtual accounts, QRIS, e-wallets, and retail counters rarely renew themselves. Subscriptions here need invoices, billing cycles, retries, and dunning, not just a charge endpoint.
When a single gateway degrades, checkout goes down with it, unless your app can prefer, filter, and route around providers.
The signature flow
BetterPay connects payment events to your business logic. When a customer pays, your app receives a normalized payment state that updates invoices, subscriptions, usage limits, and access.
pay.createTransaction()One call with a validated amount and order. BetterPay selects the provider by priority or payment method.
paymentUrlThe customer pays on the provider's hosted page: Snap, payment link, QRIS, virtual account, e-wallet, or retail counter.
pay.handleWebhook()The callback hits your route. Signature verified, replay blocked; duplicate events deduped in-memory until you add persistence.
status: "paid"Provider payloads collapse into one status vocabulary, validated against an explicit transaction state machine.
invoice / subscriptionThe paid event settles the invoice and activates the subscription period.
billing.check()The feature gate flips to allowed: true. Your customer gets what they paid for.
billing.check({ featureId: "messages" })allowed: true. Access unlocked.
End-to-end in code
One transaction, one webhook handler, one entitlement check. Billing and persistence wiring stay in your repo; defaults can start in-memory for development.
// 1) Create a payment (provider-hosted checkout)
const { paymentUrl } = await pay.createTransaction({
amount: 199_000,
currency: "IDR",
orderId: "order_8f2k",
});
// 2) Webhook on your route (signature + normalized status)
await pay.handleWebhook(request);
// 3) Entitlement after invoice / subscription settles
const access = await pay.billing.check({
customerId: "user_1",
featureId: "messages",
});
// → { allowed: true, balance: { remaining: 4999 } } Payments, webhooks, billing cycles, and customer access share one TypeScript API. Jump to what you need:
Supported today
createTransaction().
Ship with the Indonesian providers you already use. BetterPay normalizes checkout URLs, webhooks, and status behind one TypeScript surface in your app.
createTransaction() BetterPay core Methods vary by adapter. Pakasir is QRIS-focused in the current package. You keep provider accounts and credentials; BetterPay is not a new payment brand.
The differentiator
Gateway wrappers stop at the payment link. Plans, subscriptions, invoices, usage, and entitlements stay in your repo as TypeScript you can review and ship.
import { billing, feature, plan } from "@betterpay/billing";
const messages = feature({ id: "messages", type: "metered" });
const pro = plan({
id: "pro",
name: "Pro",
price: { amount: 199_000, currency: "IDR", interval: "month" },
includes: [messages({ limit: 5_000, reset: "month" })],
});
await pay.billing.subscribe({ customerId: "user_1", planId: "pro" });
await pay.billing.check({
customerId: "user_1",
featureId: "messages",
});
// → { allowed: true, balance: { remaining: 4999 } } Primitives
plan() / feature() Pricing and metered limits live next to the code that uses them.
subscription + invoice Explicit lifecycle and per-period invoices settled by payment events.
check() / report() Gate features and meter usage with lazy limit resets.
cycles + dunning Renewals, retries, and cron helpers for Indonesian billing reality.
The billing plugin ships with in-memory repositories so you can start immediately. For production, wire persistence with the Drizzle adapter or your own repositories.
Webhook reliability
Every callback runs the same pipeline before your business logic ever sees it. These are framework capabilities you configure, not magic that replaces your database and workers.
Incoming callback → verify → normalize → your handler
Provider scheme checked before state updates.
Stale callbacks rejected, not re-applied.
In-memory today; add DB for durable dedupe.
One status vocabulary across six dialects.
State machines guard illegal changes.
Audit hooks; you wire queries and workers.
Developer experience
Bring your own framework, database, auth, and deployment. Every handler wraps the same core Request to Response function, so there is no framework lock-in.
import { payHandler } from "@betterpay/next";
import { pay } from "@/lib/pay";
export const { GET, POST } = payHandler(pay);Drizzle adapter PostgreSQL repositories for transactions and billing.
CLI betterpay init, status, and encrypted credential commands.
Client SDK A typed, proxy-based client for your frontend.
Credential encryption Provider keys stored AES-256-GCM encrypted.
Use cases
Monthly and yearly IDR plans with subscriptions, invoices, billing cycles, and entitlements.
Meter API calls, messages, credits, or any usage-based limit per customer.
Courses, templates, communities, and paid tools sold through local payment methods.
One-time transactions with provider webhooks normalized into your app state.
One reusable payment layer across client apps; swap provider configuration per project.
Add fallback providers when your app needs broader payment coverage.
Seat-based access, credits, and renewals tied to invoices and local payment methods.
Honest scope
BetterPay runs inside your application. You own the routes, the database, the deployment, and the payment logic. Checkout itself happens on provider-hosted pages such as Snap or payment links.
Use one gateway today. Add fallback, subscriptions, invoices, usage, and entitlements when your product needs them, without leaving your codebase.
$ pnpm add @betterpay/core @betterpay/billing @betterpay/midtrans