Verify provider signature
Every provider's scheme, checked before a payload touches your state.
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
createTransaction().
One call is the meeting point. Route by priority, per method, or per customer segment. When a gateway degrades, the circuit breaker opens and retry utilities take over — the same context reaches every provider without rewrites.
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 dropped.
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.
The differentiator
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
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.
Every provider's scheme, checked before a payload touches your state.
Stale or re-sent callbacks are rejected instead of re-applied.
Idempotency handling out of the box, in-memory today; add persistence for durable dedupe across deployments.
Six webhook dialects become one payload shape and one status vocabulary.
A paid order cannot quietly become pending. State machines guard every change.
Audit hooks record what happened; a reconciliation worker helps you catch missed status changes, with query wiring in your hands.
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.
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