At DocRouter.AI, we build an AI app for processing documents using LLMs. As our user base grew, we needed a reliable way to handle billing. This post explains our Stripe integration, focusing on key design choices. We’ll cover why we chose Stripe, how we keep things flexible, pricing decisions, APIs used, and more.
Table of Contents
- Why Use Stripe?
- DocRouter.AI Components
- Free Tier, Plans, and A-La-Carte Credits
- Prices for Large vs. Small Customers
- Price Changing Flexibility
- The Stripe Product and Price Metadata
- Python APIs for Retrieving Products and Prices
- Stripe Checkout and Billing Portal
- Webhooks and Synchronization
- MongoDB Schema
- Tracking SPU Usage
- Environment Variables
- Development and Testing with Stripe
Why Use Stripe?
Stripe handles payments securely and scales with our app. It supports subscriptions for recurring plans, one-time charges for credits, and webhooks for real-time updates.
Customers are able to purchase credits we call SPUs (Service Processing Units) - this is our abstraction for LLM usage like token counts. Many other SAAS companies use the same credit-purchase based mechanism. Our inspiration came from Databricks, which measures credits as DBUs (Databricks Processing Units).
As long as the credit units are tied to predictable units of operation - for example, number of pages processed in a document - a credit purchase mechanism is a good choice.
Stripe reduces fraud risks with built-in tools like Radar and handles global currencies/taxes. For an AI app with variable usage, it’s essential — manual billing would be error-prone and slow.
DocRouter.AI components
Our front end is Next.JS, with user authentication implemented through Next.Auth. Our back end is Fast API. The database is MongoDB.
DocRouter supports MCP, and agentic Claude Code integration - allowing document workflows to be controlled through a simple chat interface.
Users can create an account or organization token, and can control all DocRouter.AI functions through REST APIs. A Python SDK and a Typescript SDK are available.
Having the flexibility to control DocRouter programmatically, either through agentic interfacing, or through APIs is great.
However, use tracking has to be automated, and the customer needs to be charged a small overhead over what DocRouter.AI itself is charged by the underlying LLM and cloud providers.
Stripe integration is, thus, an essential ingredient in making this kind of programmatic integration possible.
Free Tier, Plans, and A-La-Carte Credits
We want users to start free, upgrade to plans, and be able to buy extra credits without friction. Here’s how:
New orgs get 100 granted SPUs (no card needed). Additional credits can be purchased. Users can subscribe to an Individual or Team plan, at a discount over the a-la-carte credits price. Or, they can select the Enterprise plan, which is invoiced outside of Stripe.
Prices for large vs. small customers
The business challenge here is for the pricing scheme to be flexible enough to accommodate large enterprise usage, at custom prices - while also allowing self-onboarded customers.
Consumption waterfall: allowance first, then purchased, then granted. Keeps costs low for light users, upsell for heavy ones.
The prices need to be in line with what is usually charged for document AI processing - while also capturing the value of more advanced, custom document workflows.
Price changing flexibility
The engineering challenge is, on the other hand, in how to create this pricing structure in Stripe, and ensure the variable part of the config (amounts, utilization thresholds) resides in Stripe configuration rather than local DocRouter.AI code.
Updating amounts or utilization thresholds at a later time should not require coding changes in DocRouter.AI. These updates should be possible by merely chaging price configuration in Stripe.
Pricing updates, however, does not impact existing customers.
How does it all work?
The Stripe Product and Price metadata
We use Product and Price metadata in Stripe:
-
We create a Stripe DocRouter Product
-
And we assign it a
product=doc_router
key/value in the price metadata. The DocRouter.AI software detects the product using the Stripe Python API, filtering all products to find specifically the one with this key/value.
Stripe uses the following ‘objects’: Products, Prices (multiple per product), Users (one per customer), and Subscriptions (each with one or more Prices.
- We create two recurring Prices we’ll use for monthly subscriptions: the Individual Price, and the Team Price. We again use metadata to auto-detect the prices:
- The Individual Price has metadata included_spus=5000, price_type=base, tier=individual.
- The Team Price has included_spus=25000, price_type=base, tier=team

The DocRouter.AI detects the prices and the tier limits from the metadata.
Python APIs for Retrieving Products and Prices
DocRouter.AI uses the Stripe Python SDK to dynamically fetch pricing configuration at startup. Here’s how:
Retrieving All Prices with Product Data
prices = stripe.Price.list(active=True, expand=['data.product'])
The expand=['data.product']
parameter loads the full product object (including metadata) alongside each price in a single API call.
Filtering by Product Metadata
product_prices = [
price for price in prices.data
if price.product.metadata.get('product') == 'doc_router'
]
This filters prices to only those belonging to our DocRouter product.
Parsing Price Metadata
for price in product_prices:
metadata = price.metadata
price_type = metadata.get('price_type')
if price_type == 'base':
tier = metadata.get('tier')
included_spus = metadata.get('included_spus')
# Store tier limits for subscription plans
All pricing configuration lives in Stripe—we can update prices and tier limits without code changes.
Wrapping Stripe APIs for Async
Since DocRouter.AI uses FastAPI (an async framework), we wrap Stripe’s synchronous API calls to run in a thread pool:
async def _run_in_threadpool(func, *args, **kwargs):
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, partial(func, *args, **kwargs))
This prevents blocking the event loop. In practice, we call:
prices = await _run_in_threadpool(
stripe.Price.list,
active=True,
expand=['data.product']
)
The wrapper runs the synchronous stripe.Price.list()
in a separate thread, keeping our async FastAPI endpoints responsive.
Stripe Checkout and Billing Portal
Purchasing Credits
When users buy a-la-carte credits, we create a Stripe Checkout session:
session = stripe.checkout.Session.create(
customer=stripe_customer_id,
payment_method_types=['card'],
line_items=[{...}],
success_url=success_url,
cancel_url=cancel_url,
metadata={'org_id': org_id, 'credits': credits}
)

Users are redirected to Stripe’s hosted checkout page—we never see their credit card details. Stripe handles all payment security.
Managing Subscriptions
For subscription management, we use Stripe’s Billing Portal:
session = stripe.billing_portal.Session.create(
customer=stripe_customer_id,
return_url=return_url
)
The portal lets users update payment methods, view invoices, and cancel subscriptions—all handled by Stripe.

Webhooks and Synchronization
Stripe sends webhooks for important events. We verify and process them:
event = stripe.Webhook.construct_event(
payload,
signature_header,
webhook_secret
)
Key events we handle:
checkout.session.completed
- Add purchased creditscustomer.subscription.updated
- Sync subscription changescustomer.subscription.deleted
- Clear subscription datainvoice.payment_succeeded
- Record successful payments
To prevent double-crediting, we track processed transactions in the db.payments_credit_transactions
collection.
On startup and via webhooks, we sync Stripe data to MongoDB. This keeps local data fresh without constant API calls.
MongoDB Schema
We store payment data in MongoDB collections:
payments_customers
- One document per organization:
{
org_id: "...",
user_id: "...",
stripe_customer_id: "cus_...",
user_name: "...",
user_email: "...",
// Subscription fields
subscription_type: "individual" | "team" | "enterprise",
subscription_spu_allowance: 5000,
subscription_spus_used: 1234, // Reset each billing period
stripe_subscription_id: "sub_...",
stripe_subscription_item_id: "si_...",
stripe_subscription_status: "active",
stripe_current_billing_period_start: 1234567890,
stripe_current_billing_period_end: 1237246290,
// Credit fields
purchased_credits: 10000,
purchased_credits_used: 5000,
granted_credits: 100,
granted_credits_used: 50,
// Metadata
created_at: ISODate(...),
updated_at: ISODate(...),
subscription_updated_at: ISODate(...)
}
Subscription SPU usage (subscription_spus_used
) is atomically reset each billing period. Subscription allowances renew monthly. Purchased and granted credits persist until consumed.
payments_credit_transactions
- Audit trail for credit purchases:
{
session_id: "cs_...",
org_id: "...",
credits: 1000,
processed_at: ISODate(...)
}
payments_usage_records
- Log of all SPU usage:
{
org_id: "...",
spus: 42,
operation: "document_processing",
source: "backend",
timestamp: ISODate(...),
llm_provider: "anthropic",
llm_model: "claude-3-5-sonnet-20241022",
prompt_tokens: 1234,
completion_tokens: 567,
total_tokens: 1801,
actual_cost: 0.023
}
Tracking SPU Usage
When LLM calls are made, we increment SPU usage:
async def record_payment_usage(org_id, spus):
# Consumption order: subscription → purchased → granted
balances = await get_current_balances(db, org_id)
consumption = calculate_consumption_breakdown(spus, balances)
# Update balances atomically
await update_customer_balances(db, customer_id, consumption)
# Save usage record
await save_complete_usage_record(db, org_id, spus, consumption)
The consumption waterfall ensures subscription credits are used first, then purchased, then granted.
Users view their credit utilization on the usage page. All data comes from MongoDB — no Stripe API calls needed to track usage, keeping the UI fast.
Environment Variables
Three Stripe-related environment variables configure the integration:
STRIPE_SECRET_KEY
- Your Stripe API key for authentication. Required to enable Stripe integration.
STRIPE_WEBHOOK_SECRET
- Secret for verifying webhook signatures. Ensures webhook events are legitimate and not forged.
STRIPE_PRODUCT_TAG
- The product identifier in metadata (default: "doc_router"
). Allows filtering prices to find only those belonging to your product.
If STRIPE_SECRET_KEY
is not set, Stripe integration is disabled and DocRouter operates in local-only mode.
Development and Testing with Stripe
Stripe provides separate test and production environments. During development, we use test mode keys:
# .env for development
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRODUCT_TAG=doc_router
Test mode keys (sk_test_*
) access a completely separate sandbox with its own customers, subscriptions, and products. You can:
- Create test products and prices in the Stripe Dashboard
- Use test credit cards (like
4242 4242 4242 4242
) for checkout - Trigger webhooks manually to test event handling
- View all transactions without affecting production data
For production, swap to live keys (sk_live_*
). The same code works in both modes—Stripe automatically routes API calls to the correct environment based on the key prefix.
This separation lets us develop and debug payment flows safely without risking real customer data or charges.