Payment and billing that lives inside your app.
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" }) Provider plugins
Six providers. One createTransaction()
Run several at once: priority order, per-method routing, a circuit breaker per provider, and retry utilities for when a gateway degrades. Payment method support depends on provider capabilities.
The problem
Payment code becomes product logic faster than you think.
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.
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.
Webhooks are fragile.
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.
Indonesian billing is not always auto-debit.
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.
Provider failure becomes product failure.
When a single gateway degrades, checkout goes down with it, unless your app can prefer, filter, and route around providers.
The signature flow
From payment request to customer access.
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()Create transaction
One call with a validated amount and order. BetterPay selects the provider by priority or payment method.
paymentUrlProvider checkout
The customer pays on the provider's hosted page: Snap, payment link, QRIS, virtual account, e-wallet, or retail counter.
pay.handleWebhook()Secure webhook
The callback hits your route. Signature verified, replay blocked, duplicate events dropped.
status: "paid"Normalized status
Provider payloads collapse into one status vocabulary, validated against an explicit transaction state machine.
invoice / subscriptionInvoice and subscription
The paid event settles the invoice and activates the subscription period.
billing.check()Entitlement access
The feature gate flips to allowed: true. Your customer gets what they paid for.
- signature verified
- replay blocked
- event deduplicated
billing.check({ featureId: "messages" })allowed: true. Access unlocked.
The differentiator
Define your billing model in code.
Most gateway wrappers stop at the payment link. BetterPay keeps going: plans, subscriptions, invoices, usage, and entitlements are first-class TypeScript, living in your repo and reviewed like the rest of your product.
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" })],
});
// Subscribe a customer
await pay.billing.subscribe({
customerId: "user_1",
planId: "pro",
});
// Gate features at request time
await pay.billing.check({
customerId: "user_1",
featureId: "messages",
});
// → { allowed: true, balance: { remaining: 4999 } }
// Meter usage
await pay.billing.report({
customerId: "user_1",
featureId: "messages",
amount: 1,
}); plan() / feature() Pricing, boolean flags, and metered limits declared next to the code that uses them.
subscription A five-state lifecycle with an explicit transition table. No mystery states.
invoice Generated per billing period and settled by normalized payment events.
entitlement check / report One call to gate a feature, one call to meter usage, with lazy limit resets.
billing cycle runner + dunning Renewals, retries for failed billing, and cron helpers for scheduled runs.
test clock Simulate months of billing cycles in a test run instead of waiting for them.
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
Webhooks should update your state, not corrupt it.
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.
Verify provider signature
Every provider's scheme, checked before a payload touches your state.
Prevent replay attacks
Stale or re-sent callbacks are rejected instead of re-applied.
Deduplicate provider events
Idempotency handling out of the box, in-memory today; add persistence for durable dedupe across deployments.
Normalize the event
Six webhook dialects become one payload shape and one status vocabulary.
Validate the transition
A paid order cannot quietly become pending. State machines guard every change.
Log and reconcile
Audit hooks record what happened; a reconciliation worker helps you catch missed status changes, with query wiring in your hands.
Developer experience
Built for modern TypeScript backends.
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
For products that need more than a payment button.
SaaS subscriptions
Monthly and yearly IDR plans with subscriptions, invoices, billing cycles, and entitlements.
API products
Meter API calls, messages, credits, or any usage-based limit per customer.
Digital products
Courses, templates, communities, and paid tools sold through local payment methods.
E-commerce checkout
One-time transactions with provider webhooks normalized into your app state.
Agency projects
One reusable payment layer across client apps; swap provider configuration per project.
Provider redundancy
Add fallback providers when your app needs broader payment coverage.
Honest scope
A framework you embed. Not another black box.
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.
BetterPay is
- An open-source payment and billing framework, MIT licensed
- A TypeScript API for Indonesian payment workflows
- A plugin layer for local payment providers
- A webhook reliability layer
- A billing cycle and entitlement engine
- A framework-agnostic backend package
BetterPay is not
- Not a hosted payment SaaS
- Not a checkout UI builder today
- Not a global Stripe replacement
- Not a marketplace or multi-merchant platform out of the box
- Not a guaranteed refund API for every local payment method
- Not a replacement for your payment gateway account
Start with one provider. Grow into billing.
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