Introducing BetterPay for Indonesian developers

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.

src/payments.ts
await pay.createTransaction({ amount: 199_000, currency: "IDR" })
BETTERPAY CORE
Amount validation passed
Provider routing selected
Webhook signature verified
Midtrans
Xendit
Duitku
Tripay
Mayar
Pakasir

Provider plugins

Six providers. One createTransaction()

Midtrans Xendit Duitku Tripay Mayar Pakasir

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.

  1. pay.createTransaction()

    Create transaction

    One call with a validated amount and order. BetterPay selects the provider by priority or payment method.

  2. paymentUrl

    Provider checkout

    The customer pays on the provider's hosted page: Snap, payment link, QRIS, virtual account, e-wallet, or retail counter.

  3. pay.handleWebhook()

    Secure webhook

    The callback hits your route. Signature verified, replay blocked, duplicate events dropped.

  4. status: "paid"

    Normalized status

    Provider payloads collapse into one status vocabulary, validated against an explicit transaction state machine.

  5. invoice / subscription

    Invoice and subscription

    The paid event settles the invoice and activates the subscription period.

  6. billing.check()

    Entitlement access

    The feature gate flips to allowed: true. Your customer gets what they paid for.

BETTERPAY / EVENT LOGorder_8f2k
createTransaction Rp 199.000pendingpaid
checkout provider hostedopened
incoming webhook
  • signature verified
  • replay blocked
  • event deduplicated
normalize settlement → paid1 event
INV-2026-0001 settledplan: pro
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.

src/billing.ts
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.

app/api/pay/[...all]/route.ts
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