End-user billing for tenant SaaS
Let your tenants resell their own plans to their own end-users, using their own Stripe account. odnoga is the routing + metering layer; Stripe is the merchant.
Selling usage rather than seats? Per-user credits are the other half of this — a balance odnoga debits per request and enforces at zero. The two compose: a plan that grants credits monthly.
Architecture
Your end-user → Your SaaS UI ──► odnoga ──(tenant sk_live_)──► Tenant Stripe
│ │
│ ▼
│ subscription state (odnoga)
│ ▲
└── Tenant Stripe ──webhook (HMAC)──┘
Two integration modes — same endpoints:
| Mode | Auth | Use when |
|---|---|---|
| Server (recommended) | Authorization: Bearer sk_live_… | You have a backend. Most secure. |
| Browser (zero-backend) | Authorization: Bearer eut_… | Pure SPAs. end_user_id is bound to the token. |
One-time setup (per tenant)
-
Connect Stripe — TenantAdmin → Stripe account. odnoga accepts a restricted key only, never an unrestricted
sk_. In Stripe: Developers → API keys → Create restricted key, set every category to None except these five, then paste therk_live_…(orrk_test_…) key:Permission Why Customers — write Create the Stripe customer behind each of your end users Checkout Sessions — write Start a subscription Billing Portal Sessions — write Let a user manage their own card and plan Subscriptions — read Feature-gate on current plan Meter events (Billing) — write Report metered usage odnoga verifies the key against Stripe before storing it, so a wrong or under-permissioned key is caught here rather than by one of your customers at a checkout page. With only these five, odnoga cannot create a charge, issue a refund, read your balance, or move money — whatever odnoga does, and whatever happens to odnoga.
-
Define plans — TenantAdmin → Subscription tiers. Each plan maps to one or more Stripe price ids (subscription, requests, tokens, …).
-
Webhook — TenantAdmin → End-user billing:
- Copy the webhook URL (per-workspace).
- In Stripe → Developers → Webhooks → "Add endpoint", paste URL, add events:
checkout.session.completed,customer.subscription.*,invoice.paid,invoice.payment_failed. - Copy the signing secret (
whsec_…) and save it back in odnoga.
-
Allowed redirect hosts — list every hostname allowed in
success_url/cancel_url/return_url. Required for browser callers, blocks open-redirect abuse.
That's it.
API
Every URL below is returned verbatim by the MCP tool platform.endpoints — call it rather than assembling paths by
hand, and use the values it returns. There is no /v1/billing/… route; the names in this section are labels for
the endpoints, not paths you can concatenate. The Node SDK wraps them as client.billing.*.
| What | Endpoint | platform.endpoints key |
|---|---|---|
| List plans | GET {API_HOST}/functions/v1/airouter-tenant-plans-list | billing_plans |
| Start checkout | POST {API_HOST}/functions/v1/airouter-tenant-checkout-session | billing_checkout |
| Billing portal | POST {API_HOST}/functions/v1/airouter-tenant-billing-portal | billing_portal |
| Read subscription | GET {API_HOST}/functions/v1/airouter-tenant-subscription?end_user_id=… | billing_subscription |
| Mint an EUT | POST {API_HOST}/functions/v1/airouter-session/v1/sessions | eut_sessions |
List plans — billing_plans
Authorization: Bearer sk_live_… # or eut_…
Returns the catalog. EUT callers only see self_serve=true plans.
{ "plans": [
{ "slug": "free", "display_name": "Free", "monthly_request_limit": 1000, "trial_days": null, "self_serve": true, "public_price_summary": { "price_usd": 0 } },
{ "slug": "growth", "display_name": "Growth", "monthly_request_limit": 50000, "trial_days": 14, "self_serve": true, "public_price_summary": { "price_usd": 29 } }
] }
Start checkout — billing_checkout
Authorization: Bearer sk_live_…
Idempotency-Key: <unique-per-attempt>
{ "end_user_id": "user_42",
"plan_slug": "growth",
"success_url": "https://app.acme.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
"cancel_url": "https://app.acme.com/pricing",
"email": "user@acme.com",
"name": "Jane Doe" }
Response: { "id": "cs_...", "url": "https://checkout.stripe.com/c/pay/…" } → redirect the browser.
With an EUT, omit end_user_id (the token wins). The plan must be self_serve=true and the redirect URLs must match an entry in allowed_redirect_hosts.
Environment-aware. odnoga derives live / test from your tenant Stripe secret prefix (sk_test_… → test). Plans, prices, customers and subscriptions are scoped per environment, so the same plan_slug can have different Stripe price ids in test and live.
PII. email and name (if provided) are encrypted at rest under a key odnoga never stores beside the data.
Lookup by email still works, through a keyed hash rather than the value itself. Tenant admins can read the plaintext
for their own workspace in the End-users panel; it is never returned by the API, and never reaches a model vendor.
See Data protection.
Billing portal — billing_portal
{ "end_user_id": "user_42",
"return_url": "https://app.acme.com/billing" }
Returns { "url": "https://billing.stripe.com/p/…" } — the Stripe Billing Portal. Handles invoices, card updates, plan changes, cancellation.
Read subscription — billing_subscription
GET …airouter-tenant-subscription?end_user_id=user_42
{ "subscription": {
"stripe_subscription_id": "sub_…",
"plan_slug": "growth",
"plan_name": "Growth",
"status": "active",
"current_period_end": "2026-07-01T00:00:00Z",
"cancel_at_period_end": false,
"trial_end": null,
"latest_invoice_status": "paid"
} }
Use this for feature gating. The row is updated in real time by webhooks.
Server-side example (Node)
import { odnoga } from '@odnoga/node';
const ar = new odnoga({ apiKey: process.env.AIROUTER_KEY! });
// 1. show pricing
const { plans } = await ar.billing.plans.list();
// 2. start checkout
const { url } = await ar.billing.checkout({
endUserId: session.user.id,
planSlug: 'growth',
successUrl: 'https://app.acme.com/billing/success',
cancelUrl: 'https://app.acme.com/pricing',
idempotencyKey: `checkout-${session.user.id}-${Date.now()}`,
});
res.redirect(url);
// 3. feature gate
const { subscription } = await ar.billing.subscription({ endUserId: session.user.id });
if (subscription?.status !== 'active') return res.status(402).send('Upgrade required');
Browser example (EUT)
// Mint the EUT server-side, hand it to the SPA. The endpoint is `eut_sessions`
// from platform.endpoints — POST …/airouter-session/v1/sessions with your
// sk_live_ key and the end_user_id you want the token bound to.
// Absolute — your SPA is on your own domain, odnoga is not.
const r = await fetch('https://api.odnoga.com/functions/v1/airouter-tenant-checkout-session', {
method: 'POST',
headers: { Authorization: `Bearer ${eut}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
plan_slug: 'growth',
success_url: 'https://app.acme.com/billing/success',
cancel_url: 'https://app.acme.com/pricing',
}),
});
window.location = (await r.json()).url;
Security model
| Concern | Default |
|---|---|
| Tenant Stripe key | Restricted keys only — an unrestricted sk_ is refused at the form, at the API and in the database. Held in a dedicated secrets vault, never beside the data that references it. Set, rotated and cleared only through the odnoga UI. Never returned by any endpoint; the screen shows the eight-character prefix, which is the mode (rk_live_ / rk_test_) and not the secret. Every write is audited. |
| Webhook signing secret | Same vault-backed flow as the Stripe key. HMAC-verified per tenant on every event. Wrong secret → 400. |
| End-user PII (email, name, phone) | Encrypted at rest. Searchable by a keyed hash of the lowercased email rather than by the value. Plaintext readable only by tenant admins of the owning workspace, in the app. |
| Company PII (tax id, billing address, contact) | Encrypted at rest under the same scheme. |
| Environment isolation | Plans, customers and subscriptions are keyed by environment throughout. Test ⇄ live can never collide, even with the same plan_slug. |
| Duplicate webhooks | Deduped on Stripe's event id; a payload digest is retained for forensics. |
| Open redirect | success_url/cancel_url/return_url validated against allowed_redirect_hosts for EUT callers. |
end_user_id spoofing | When called with EUT, the body value is ignored and the token claim wins. |
| Idempotency | All POST accept Idempotency-Key. Every issued checkout session is recorded, so a retry returns the original rather than charging twice. |
| Audit | Every settings change — Stripe key set, rotated or cleared, webhook secret, redirect hosts — is written to the audit log, visible to tenant admins. |
Error codes
| HTTP | error.message | Meaning |
|---|---|---|
| 401 | invalid api key or eut | Bad/missing bearer |
| 400 | end_user_id required | Missing on server call (EUT supplies it) |
| 400 | redirect_url_rejected: … | URL not in allowed_redirect_hosts |
| 403 | plan not self-serve | EUT cannot subscribe to this plan |
| 404 | plan not found | Wrong slug / plan disabled |
| 412 | tenant Stripe not connected | Tenant has not connected Stripe |
| 412 | plan has no Stripe prices configured | Plan missing price ids |
| 412 | webhook signing secret not configured | Tenant has not pasted whsec_… |
| 502 | Stripe message | Upstream failure |
MCP agents
odnoga ships MCP tools so coding agents wire this end-to-end without docs:
billing.setup_guide— print the runbook.billing.plans.list— discover slugs.billing.subscription.get— read current plan for feature gating.billing.checkout.example— return ready-to-paste cURL/Node/browser snippets for a chosen plan.
Call billing.setup_guide first.