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:

ModeAuthUse 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)

  1. 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 the rk_live_… (or rk_test_…) key:

    PermissionWhy
    Customers — writeCreate the Stripe customer behind each of your end users
    Checkout Sessions — writeStart a subscription
    Billing Portal Sessions — writeLet a user manage their own card and plan
    Subscriptions — readFeature-gate on current plan
    Meter events (Billing) — writeReport 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.

  2. Define plans — TenantAdmin → Subscription tiers. Each plan maps to one or more Stripe price ids (subscription, requests, tokens, …).

  3. 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.
  4. 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.*.

WhatEndpointplatform.endpoints key
List plansGET {API_HOST}/functions/v1/airouter-tenant-plans-listbilling_plans
Start checkoutPOST {API_HOST}/functions/v1/airouter-tenant-checkout-sessionbilling_checkout
Billing portalPOST {API_HOST}/functions/v1/airouter-tenant-billing-portalbilling_portal
Read subscriptionGET {API_HOST}/functions/v1/airouter-tenant-subscription?end_user_id=…billing_subscription
Mint an EUTPOST {API_HOST}/functions/v1/airouter-session/v1/sessionseut_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

ConcernDefault
Tenant Stripe keyRestricted 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 secretSame 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 isolationPlans, customers and subscriptions are keyed by environment throughout. Test ⇄ live can never collide, even with the same plan_slug.
Duplicate webhooksDeduped on Stripe's event id; a payload digest is retained for forensics.
Open redirectsuccess_url/cancel_url/return_url validated against allowed_redirect_hosts for EUT callers.
end_user_id spoofingWhen called with EUT, the body value is ignored and the token claim wins.
IdempotencyAll POST accept Idempotency-Key. Every issued checkout session is recorded, so a retry returns the original rather than charging twice.
AuditEvery settings change — Stripe key set, rotated or cleared, webhook secret, redirect hosts — is written to the audit log, visible to tenant admins.

Error codes

HTTPerror.messageMeaning
401invalid api key or eutBad/missing bearer
400end_user_id requiredMissing on server call (EUT supplies it)
400redirect_url_rejected: …URL not in allowed_redirect_hosts
403plan not self-serveEUT cannot subscribe to this plan
404plan not foundWrong slug / plan disabled
412tenant Stripe not connectedTenant has not connected Stripe
412plan has no Stripe prices configuredPlan missing price ids
412webhook signing secret not configuredTenant has not pasted whsec_…
502Stripe messageUpstream 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.