Inspiration

Anyone can call an LLM now. Charging someone for it is the hard part.

The moment you put AI behind a paywall you are building a billing system: per-token cost attribution, a prepaid balance, admission control so you never serve a request you can't bill, a payments rail, and a ledger that survives retries and duplicate webhooks. Most teams build a homegrown version of that — badly, twice.

Two things convinced us this was worth solving properly. First, PayPal's Usage Based Billing API v1 already records usage events, settles them against a prepaid wallet and issues credit notes — but it does not meter AI inference. It has no per-token attribution and no way to refuse a call before it costs you money. Second, OpenRouter proved the business model works: one unified inference endpoint with metered billing on top. But that stack is theirs.

So the question became: what if the metering layer was something anyone could run? Not a SaaS you sign up for — a stack you drop in front of your app, with your own models, your own prices, and your own PayPal account.

What it does

MeterPal meters every AI call per end user — tokens to cost to credits — debits a prepaid wallet with atomic admission control, and moves every dollar through PayPal.

The behaviour everything else is built around: a call the customer can't afford is refused with HTTP 402 before the model provider is ever touched. No overspend, no surprise invoices, no reconciliation. The 402 body carries the current balance and the credits the call required, so the client can open a PayPal checkout and resume.

One SDK, and your app's AI usage bills itself.

How we built it

The billing server is a fork of Autumn (Apache-2.0), with MeterPal's PayPal rail built on top. It owns the wallet ledger, the credit systems, the catalog, and virtual-key provisioning.

The metered gateway is a fork of LiteLLM (MIT). We added exactly one file — a usage-billing hook at gateway/litellm/integrations/meterpal_billing.py — and modified no LiteLLM core files. The hook implements the call lifecycle: pre-authorize, serve, meter, settle.

Cost attribution is computed from the org's pinned price sheet, never from the provider's live pricing:

$$ \text{credits} = \frac{\text{input_tokens}}{10^6} \times r_{\text{in}} + \frac{\text{output_tokens}}{10^6} \times r_{\text{out}} $$

where $r_{\text{in}}$ and $r_{\text{out}}$ are pinned per org at /check and used verbatim at settle.

Admission control is a hot wallet counter decremented atomically in Redis via Lua, before the upstream call. Holds are reservations: they settle on success and release on failure, so a crashed request doesn't strand a customer's balance.

The PayPal integration is per-merchant, not platform-wide. Every call goes through a client built from the org's own row, with the client secret encrypted at rest and tokens cached per org. Webhooks are verified against that org's webhook id and are idempotent on the capture/sale/invoice id.

The SDKs (@meterpal/sdk for TypeScript, meterpal for Python) are OpenAI-shaped, with typed errors — a 402 surfaces as InsufficientBalanceError carrying balance and requiredCredits, and error.topUp() returns the PayPal checkout URL. The meterpal init CLI detects your framework and writes the server-side handler so the secret key never reaches a browser.

The agent layer is an MCP server exposing the merchant operations, plus two demo agents: one that lists low-balance customers and invoices overage, and one that buys its own credits when it runs out.

The docs are an OpenAPI 3.1 definition of all 18 merchant operations, with the 402 modeled as a first-class response, used to generate the API portal with APIMatic.

Challenges we ran into

Money bugs that only a judge's run finds. We ran the project the way an outsider would and found five real breakages in the demo path. Three were money bugs: the bridge sent action: "commit" when the API only accepted "confirm", so every successful request kept its reservation hold and wallets locked themselves until TTL; checkout sent product_id when the v2 pipeline read plan_id, so every top-up 404'd and the entire 402 → buy → resume story was dead; and the Python SDK parsed balances as a list when the API returns a dict, crashing the buyer agent on every balance read.

PayPal redirects with the wrong token. On subscription approval, PayPal returns the express-checkout token of the order it created — not the BA token from the approval link. We now mint our own checkout token, carry it through the return route, and resolve the subscription through it, with a payer-match fallback for links created earlier.

A column-name mismatch silently broke key provisioning. A drizzle mapping bug (customers.litellm_virtual_key, bridge_audit_log.customer_id) meant the camelCase properties were undefined, so no customer ever received a virtual key. Nothing threw — it just quietly never worked.

One webhook id can't verify every tenant. A single receiver verifying against one platform-wide id would have let one merchant's event be validated against another's secret. We moved to POST /webhooks/paypal/:orgId/:webhookId so verification is always per-org.

Keeping merchant prices stable. If we priced from the provider's live rates, a provider price change would silently move what a merchant charges their customers. So served_models is the per-org price sheet, pinned at /check and used verbatim at settle. We verified it: a call at 20/80 cost \$0.36; after setting 60/100 in the dashboard, the same call cost \$1.76.

Licensing, unexpectedly. Upstream LiteLLM ships an enterprise/ package under a proprietary paid-seat license. To publish this as genuinely open source we had to remove it, then unwind every dependency on it — the uv workspace member, the pyproject entry, the Dockerfile copies — and confirm the gateway degrades gracefully because every import of it is guarded by try/except ImportError.

What we learned

Admission control is the whole product. Metering after the fact is an invoice. Metering before the provider is touched is a business — it's the difference between discovering a customer can't pay and preventing it.

Idempotency is not optional when money is involved. Every write is keyed: metering on the gateway call id, webhook grants on the capture id. Without that, retries double-bill.

Sandbox integration surfaces bugs no test suite does. The redirect-token and column-name bugs were both invisible until a real PayPal flow ran end to end.

Open-sourcing a fork is a licensing exercise, not a git push. We ended up with a per-directory license map: AGPL-3.0 for MeterPal's code, Apache-2.0 for the Autumn fork, MIT for LiteLLM — and a commercial option that grants no rights in any of the vendored components.

Accomplishments we're proud of

  • The 402 → top-up → resume loop working end to end against a real PayPal sandbox
  • Exact cost attribution: calls metered at \$0.36 each, the 402 landing on call 9
  • An agent that can buy its own credits but cannot approve its own payment — approval stays a human click, by design
  • A demo that runs with zero external accounts: docker compose up -d, a mock LLM, and the whole paywall story plays out with no keys
  • Ten SDK assertions in TypeScript and the same ten in Python, both exiting non-zero on failure

What's next

Publishing both SDKs to npm and PyPI, adding usage-based plans on top of the current prepaid model, and a hosted tier so merchants can skip running the gateway entirely — the SDK is the whole integration.

Built with

paypal-orders-v2 paypal-subscriptions paypal-invoicing paypal-webhooks paypal-usage-based-billing litellm typescript python hono drizzle postgres redis bun react ag-grid-ag-studio mcp openapi apimatic

Share this project:

Updates

Submission history