Skip to content
SaaS Architecture5 June 2026 · 7 min readUpdated 30 September 2026

Tenant Provisioning and Onboarding Architecture for SaaS

SaaS tenant onboarding starts with provisioning: the steps, idempotency with Stripe, custom claims and first-run seeding, checked against Stripe and Firebase documentation.

Tenant Provisioning and Onboarding Architecture for SaaS

What you'll learn

  • What is the difference between tenant provisioning and onboarding?
  • What are the steps of a multi-tenant provisioning flow?
  • How do you make tenant provisioning idempotent?
Article contents

Consider a two-person team whose SaaS has a sign-up form and nothing behind it except a few database writes. Someone double-clicks the button, the request is retried, and now there are two tenants and one confused owner. Your first action: open the function that runs on sign-up and run it twice with the same user. If you get two tenants, read the idempotency section below before anything else.

This post covers saas tenant onboarding as two separate jobs. One is the machine work that makes a valid tenant exist. The other is the guided path that gets the first human to a first result. The pillar, Multi-Tenant SaaS Architecture, covers where tenant data lives and how it stays isolated. For the provisioning trigger differences between self-serve and sales-led signups, see tenant provisioning for self-serve versus sales-led SaaS.

What is the difference between tenant provisioning and onboarding?

Provisioning is the automated backend work that turns an authenticated person into a working tenant, while onboarding is the guided sequence that takes that person from signed up to getting value.

Provisioning is code. It creates the tenant record, links the first user as owner, writes the access claim, connects billing and inserts the defaults the product assumes exist. The user shouldn't see it, and it either works or it doesn't. Onboarding is UX: invite teammates, connect an integration, create the first real record. The activation side of that is covered in Building a B2B SaaS Onboarding Flow That Activates Users.

A mistake worth avoiding is doing real provisioning work inside the onboarding wizard. If the wizard is abandoned halfway, you're left with a half-built tenant. Finish provisioning before the welcome screen renders, and let onboarding operate on a tenant that is already valid.

What are the steps of a multi-tenant provisioning flow?

Run seven steps in order: authenticate, create the tenant, create the owner membership, write the access claim, wire billing, seed defaults, and mark the tenant ready. Each step assumes the previous one succeeded.

  1. Authenticate the person first. Identity says who you are. Tenancy says what you can touch. Keep them separate.
  2. Create the tenant record. One document or row with an id, a name, a plan and a status. Everything else references it.
  3. Create the owner membership. Link the user to the tenant with an owner role, since the first user manages billing and invites.
  4. Write the access claim. Put the tenant id and role into the user's token so later checks can trust it.
  5. Wire billing. Create the customer or connected account at your payment provider and store its reference on the tenant.
  6. Seed defaults. Insert what the product needs so the first screen isn't blank.
  7. Mark the tenant ready. Only now hand over to onboarding.

For a worked example, Callidus is a React, TypeScript and Firebase SaaS for UK aesthetic clinics that I built solo between February and April 2026, according to its case study. Its data lives under tenants/{tenantId}/… in Firestore, JWT claims carry tenantId and role, and each clinic onboards its own Stripe Connect Standard account. That is the same shape as steps 2, 4 and 5 above. The case study reports 14 tenants onboarded across active, trialing and suspended states by June 2026.

Whether a step can share a database transaction with the others decides how you retry it. This table is my working classification, not a rule from any vendor:

StepInside the database transaction?Safe to repeat?
Create tenant and owner membershipYesOnly with a deterministic id
Write access claimNo, it's an auth callYes, it overwrites the same value
Create billing customerNo, it's an external callYes, with an idempotency key
Seed defaultsYes, if smallYes, if keyed by stable ids
Mark readyYesYes

Why does the access claim need a token refresh?

New claims don't reach the client until its ID token is refreshed. Firebase's documentation says this happens when the user signs in again, when an existing session's older token expires, or when you force a refresh with getIdToken(true) (Firebase custom claims, retrieved 30 September 2026). The same page caps the claims payload at 1,000 bytes and says claims are for access control, not for storing profile data.

The consequence is easy to miss. If provisioning writes the claim and the client then immediately calls a protected route with its old token, the first request fails. Force a token refresh right after the provisioning call returns, and keep the claim to a tenant id and a role.

A related rule: never let the client tell the server which tenant it belongs to. The tenant id belongs in the token that provisioning wrote, and every later authorization check, whether it's Firestore rules or Postgres row-level security, should trust that value and nothing the request body says. That's a design recommendation, and it's the reason the claim has to be written at provisioning time instead of patched in later.

How do you make tenant provisioning idempotent?

Key the tenant on something stable, such as the owner's user id, and make the create step a no-op if that key already has a tenant, so a retried sign-up produces one tenant instead of two.

Stripe's guidance shows why you can't rely on a provider-side key alone. Its idempotency layer saves the status code and body of the first request for a key and returns the same result for later requests, but keys can be removed once they're at least 24 hours old, and a reused key after pruning creates a new request (Stripe idempotent requests, retrieved 30 September 2026). Use Stripe keys for the billing call, and use your own deterministic tenant id for the tenant itself.

Stripe's idempotency layer also compares incoming parameters with those of the original request and returns an error if they differ, which protects you from reusing one key for two different sign-ups. All POST requests accept keys, while GET and DELETE requests are idempotent by definition and don't need one.

Webhook-driven provisioning adds two more rules. Stripe says endpoints might occasionally receive the same event more than once, so log the event ids you've processed. It also doesn't guarantee delivery in the order events were generated, and it retries undelivered events for up to three days in live mode (Stripe webhooks, retrieved 30 September 2026). Return a 2xx response quickly and do the work asynchronously.

Here is a sketch using the Firebase Admin SDK. It isn't Callidus code. The transaction creates the tenant only if it doesn't exist, and the repeat-safe steps run on every call, so a crash between steps heals on the next attempt:

import { getFirestore } from "firebase-admin/firestore";
import { getAuth } from "firebase-admin/auth";

export async function provisionTenant(uid: string, name: string) {
  const db = getFirestore();
  const tenantRef = db.collection("tenants").doc(uid);

  await db.runTransaction(async (tx) => {
    const snap = await tx.get(tenantRef); // reads before writes
    if (snap.exists) return; // second run is a no-op
    tx.set(tenantRef, { name, plan: "trial", status: "provisioning", ownerUid: uid });
    tx.set(tenantRef.collection("members").doc(uid), { role: "owner" });
  });

  // Outside the transaction: Firestore may run the function more than once.
  await getAuth().setCustomUserClaims(uid, { tenantId: uid, role: "owner" });
  await tenantRef.update({ status: "ready" });
}

To test it, call the function twice in parallel for the same user, then call it once more after deleting the claim by hand. You should end with one tenant, one owner membership and a valid claim each time. That's my suggested check, not a documented procedure.

The comments reflect Firestore's own transaction docs: reads must come before writes, and a transaction function might run more than once if a concurrent edit affects a document it reads, so it shouldn't touch application state directly (Firestore transactions, retrieved 30 September 2026).

How do you seed a new tenant so the first screen isn't empty?

Give the tenant enough data that the first screen shows the product working, using defaults, removable sample rows or a template the user picks. An empty dashboard can leave a new owner with no obvious next action.

ApproachEffortMain risk
Default settings and structureLowScreens may still look sparse
Sample rows the user can deleteMediumSamples pollute real data if they're not clearly marked
Template chosen during onboardingHighAdds a decision before any value

Keep the guided part short. Chameleon's 2025 benchmark report, which analyzed more than 550 million user interactions, says tour completion drops sharply beyond five steps (Chameleon benchmark report, retrieved 30 September 2026). The exact percentages sit in charts I couldn't read as text, so I'm citing the direction, not a number. My inference is to aim for a first-run path of five steps or fewer and seed the rest.

I can't offer onboarding conversion numbers from my own products. BookBed, my Flutter, Firebase and Stripe booking app, has its subscription infrastructure wired and tested (trial lifecycle, €9 per month for up to twenty units) but not activated for paid traffic, so there's no paid activation data to report.

What state should a new tenant carry?

Store an explicit status on the tenant, because provisioning, trials and suspension are all states of the same record. Callidus runs a 14-day trial and a four-stage lifecycle: a three-day email, a one-day email, expiry with a three-day grace period, then suspension. A coordinator Cloud Function runs daily at 09:00 UTC and fans out one Cloud Task per trialing tenant, and during the grace period pages stay viewable while mutations are blocked at both the route and the Firestore rules layers. That lifecycle is where the status field earns its place. The states themselves are covered in SaaS subscription lifecycle states, and webhook handling patterns in webhook reliability, idempotency and dead-letter queues.

Run your own sign-up function twice with the same user today. If it creates two tenants, which of the seven steps would you key on a stable id first?

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

What is tenant provisioning in a multi-tenant SaaS?

Tenant provisioning is the automated backend sequence that turns a newly authenticated person into a working tenant. It creates the tenant record, links the first user as owner, writes the access claim, connects billing and inserts default data. It should finish before the welcome screen renders, so the user never lands on a half-built account. Onboarding is separate: it is the guided path that takes that user to a first result. Keeping the two apart means an abandoned onboarding wizard can never leave a tenant in a broken state.

How do I make SaaS tenant provisioning idempotent?

Key the tenant on a stable identifier, such as the owner's user id, and make the create step do nothing when that key already has a tenant. Stripe's idempotency keys can be pruned once they are at least 24 hours old, so they protect the billing call but should not be the only guard for the tenant itself. Run the repeat-safe steps, such as writing the claim and marking the tenant ready, on every call so a crash between steps heals on the next attempt. Then test by calling the function twice in parallel for the same user.

Should I provision the tenant at sign-up or after payment?

It depends on your model. For a free trial, provision at sign-up so the user reaches the product straight away. For paid-first products, provision when payment is confirmed, and make that handler idempotent. Stripe says webhook endpoints might occasionally receive the same event more than once and does not guarantee delivery in the order events were created, so log processed event ids and do not depend on ordering. Return a 2xx response quickly and process the work asynchronously so a slow handler does not trigger retries.

What happens if tenant provisioning fails halfway?

Nothing should be left half-built. Put the database writes for the tenant and owner membership in one transaction so a failure rolls them back together. External calls, such as creating a billing customer or setting an auth claim, cannot join that transaction, so make them safe to repeat and run them on every attempt. Firestore's documentation warns that a transaction function might run more than once, so it should not modify application state directly. Mark the tenant ready only after the last step succeeds.