Edit Page

Multi-tenancy

RESTHeart
Prefer not to run it yourself? Ulabase is this, hosted, with sign-up, payments and an MCP server already on. Free to start. Get started →

A single RESTHeart instance can serve many tenants, each with its own Stripe account, plan catalog, database and redirect URLs.

Every value in stripeConfig can be overridden per request. When no override is attached, the static configuration applies β€” so a single-tenant deployment needs to know nothing about this mechanism.

How it works

A deployment-layer interceptor running at REQUEST_BEFORE_EXCHANGE_INIT resolves the tenant (typically from the hostname), reads that tenant’s configuration, and attaches it to the request as override parameters.

Every service then reads the effective value: the override if present, the static one otherwise.

flowchart LR
  R[Request\nacme.example.com] --> I["TenantConfigInterceptor\nREQUEST_BEFORE_EXCHANGE_INIT"]
  I -- "attaches override-stripe-*" --> S[stripe services]
  S --> K["effective secret key,\ncatalog, db, URLs"]

The interceptor is not part of restheart-stripe β€” it belongs to the deployment layer, exactly as AuthDbResolver and TeamConfigInterceptor do for restheart-accounts. No plugin configuration changes per tenant.

Override parameters

Request attribute Overrides Effect

override-stripe-secret-key

stripeConfig.secret-key

The Stripe account this request bills against

override-stripe-webhook-secret

stripeConfig.webhook-secret

Signing secret used to verify this request’s webhook signature

override-stripe-plans

stripeConfig.plans

Replaces the entire catalog β€” see below

override-stripe-default-plan

stripeConfig.default-plan

Plan for an entity with no subscription

override-stripe-products

stripeConfig.products

Replaces the entire products configuration β€” same replace-wholesale contract as override-stripe-plans, see below

override-stripe-db

mongoRealmAuthenticator.users-db

Database holding this tenant’s entity documents

override-stripe-teams-collection

stripeConfig.teams-collection

Collection holding them

override-stripe-success-url

stripeConfig.success-url

Per-tenant Checkout success redirect

override-stripe-cancel-url

stripeConfig.cancel-url

Per-tenant Checkout cancel redirect

override-stripe-portal-return-url

stripeConfig.portal-return-url

Per-tenant Portal return URL

override-stripe-products

stripeConfig.products

Replaces the entire products configuration β€” same wholesale-replacement contract as override-stripe-plans, not a field-by-field override. Absent means products mode is off for this tenant.

override-stripe-tmpl-{name}

built-in / configured template

Inline HTML for one notification template β€” subscription and order notifications alike, see below

override-stripe-notify-{name}-enabled

stripeConfig.subscriptions.notifications.{name}.enabled

Enables or disables one subscription notification. Order notifications have no equivalent: their enabled flag travels inside override-stripe-products itself, in products.notifications.{name}.enabled

override-accounts-ownership-role

accountsConfig.ownership-role

Which team role may manage billing. Shared with restheart-accounts β€” see below

{name} in override-stripe-tmpl-{name} is one of the subscription notifications (payment-failed, trial-will-end, subscription-canceled, over-limit) or the order notifications (order-confirmed, order-refunded) β€” the parameter is generic on the name, so both sets are attached the same way and neither collides with the other.

Kill switches

Distinct from the overrides above by both prefix (rh-, not override-) and meaning: an override replaces a value, a kill switch removes the module from the request entirely. Presence of the parameter disables β€” the value itself is never inspected, so attaching Boolean.FALSE still disables.

Request attribute Effect

rh-stripe-subscriptions-disabled

Every subscriptions-mode service (stripeCheckoutService, stripePortalService, stripeSubscriptionService, stripePlansService, stripeLicensesService, stripeWebhookService) stops responding, and @subscription resolves to unresolved β€” the same outcome as any other resolution failure, not a special case.

rh-stripe-products-disabled

ordersCheckoutInterceptor and ordersCheckoutResponseInterceptor stop running.

Use these for a tenant that has not installed, or has disabled, the corresponding mode β€” instead of building a separate access gate in the deployment layer. A tenant with an incomplete configuration (installed, but no secret-key yet) is a different case and is not covered by these switches: answer that with your own 503, since disabling would misreport "not installed" for a tenant that is mid-setup.

Per-tenant Stripe accounts

Important

The module never assigns the API key globally.

The Stripe Java SDK exposes a global Stripe.apiKey, and using it in a multi-tenant server is a data-leak bug waiting to happen: it is process-wide mutable state, so two requests for different tenants overlapping in time can send one tenant’s call with the other’s key β€” creating a customer, a subscription or a refund in the wrong Stripe account.

restheart-stripe passes the effective key explicitly to every SDK call via RequestOptions. There is no window in which one tenant’s key applies to another tenant’s request, regardless of concurrency.

If you write code against the Stripe SDK alongside this module, follow the same rule:

// WRONG in a multi-tenant deployment β€” process-wide mutable state
Stripe.apiKey = tenantSecretKey;
Customer.create(params);

// RIGHT β€” scoped to this call
var opts = RequestOptions.builder().setApiKey(tenantSecretKey).build();
Customer.create(params, opts);

Overriding the plan catalog

override-stripe-plans is different from the other overrides in two ways.

It must be a fully parsed value. Attach a Map<String, PlanConfig>, not a raw YAML fragment β€” the interceptor does the parsing, once, rather than the module re-parsing on every request.

It replaces the catalog wholesale. There is no merging with the static catalog. A partial override would leave a tenant on a mix of two catalogs, where a plan id present in one and absent from the other resolves differently depending on which half answered β€” the kind of bug that surfaces as one customer on the wrong price.

var plans = Map.of(
    "free", new PlanConfig(null, null, null,
                new SeatsConfig(SeatsMode.CAPPED, 1), Map.of()),
    "gold", new PlanConfig("price_tenantA_monthly", "price_tenantA_annual", 30,
                new SeatsConfig(SeatsMode.CAPPED, 25), Map.of("max-projects", 100)));

request.attachParam("override-stripe-plans", plans);

Build each PlanConfig with PlanConfig.fromMap(Map<String, Object>) rather than the constructor directly β€” it is the same parser stripeConfig’s own YAML loader uses (field names, `seats.mode tolerance for an unrecognized value falling back to UNLIMITED, and so on), so a tenant’s plan read from MongoDB and one read from YAML can never silently drift apart:

var plans = new LinkedHashMap<String, PlanConfig>();
for (var entry : tenantPlansDoc.entrySet()) {
    plans.put(entry.getKey(), PlanConfig.fromMap((Map<String, Object>) entry.getValue()));
}
request.attachParam("override-stripe-plans", plans);

Overriding the products configuration

override-stripe-products follows the same contract as override-stripe-plans: a fully-parsed ProductsConfig, attached by the deployment’s interceptor, replacing the static configuration wholesale.

request.attachParam("override-stripe-products", tenantProductsConfig);

A tenant without products mode simply never has this attached; the module falls back to stripeConfig.products, which is null in that case exactly as it is statically.

Webhooks in a multi-tenant deployment

The webhook path is always /stripe/webhook β€” there is no per-tenant path segment.

Because each tenant has its own signing secret, the interceptor must attach the correct override-stripe-webhook-secret before stripeWebhookService verifies the signature. Resolve the tenant the same way as any other request β€” normally from the hostname:

https://acme.example.com/stripe/webhook   -> tenant "acme"   -> whsec_acme…
https://globex.example.com/stripe/webhook -> tenant "globex" -> whsec_globex…

Register the tenant’s own hostname as the endpoint URL in each tenant’s Stripe Dashboard.

Warning

If the interceptor cannot resolve a tenant, the static webhook-secret applies. When tenants have different secrets, the signature then fails and the event is rejected with 400 β€” Stripe retries for a while and eventually gives up, and that tenant’s subscriptions silently stop updating.

Monitor `400`s on the webhook endpoint. In a multi-tenant deployment they usually mean tenant resolution failed, not that someone is forging requests.

An event whose Customer belongs to no entity in the resolved scope is logged and ignored β€” which is also what a mis-resolved tenant looks like:

[stripe] customer.subscription.updated for unknown customer cus_xyz β€” no entity is linked to it

Per-tenant databases

override-stripe-db and override-stripe-teams-collection bind the default SubscriptionOwnerProvider. A custom provider receives the resulting BillingScope and may ignore it.

Important

stripeInitializer creates the stripe_customer_id unique index at startup, against the statically configured database only β€” by calling StripeInitService.initSubscriptions() internally.

A deployment whose database varies per tenant calls the same service itself, once per tenant, when that tenant enables the plugin:

@Inject("stripeInitService")
private StripeInitService initService;

initService.initSubscriptions(tenantDbName);
// and, if that tenant also has products mode:
initService.initProducts(tenantDbName);

Idempotent, so calling it again β€” a tenant re-enabling the plugin, or a deployment that prefers calling it unconditionally at provisioning time β€” is safe.

Without the index, byStripeCustomerId does a collection scan on every webhook delivery, and nothing prevents two entities from being linked to the same Stripe Customer. See Indexes for the full picture, including `initProducts()’s additional index and schema work.

Relationship to restheart-accounts

override-accounts-ownership-role is shared with restheart-accounts rather than duplicated under a stripe name.

The two modules must agree on who owns a team: if they disagreed, a caller could be an owner for invitations and not for billing, or the reverse. Sharing the parameter makes that disagreement impossible.

The coupling is a naming convention, not a compile-time dependency β€” restheart-stripe does not depend on restheart-accounts. If the parameter is absent, because accounts is not deployed or not overriding it, the static value applies.

Example interceptor

@RegisterPlugin(
        name = "tenantStripeConfigInterceptor",
        description = "Attaches per-tenant stripe overrides",
        interceptPoint = InterceptPoint.REQUEST_BEFORE_EXCHANGE_INIT)
public class TenantStripeConfigInterceptor implements WildcardInterceptor {

    @Inject("mclient")
    private MongoClient mclient;

    @Override
    public boolean resolve(ServiceRequest<?> req, ServiceResponse<?> res) {
        // Not scoped to "/stripe/*": @subscription must resolve on ANY request an ACL
        // predicate might name it in β€” a plan gate on /api/reports needs the override
        // exactly as much as /stripe/checkout does. Scoping resolve() to the stripe paths
        // is the obvious-looking trap here β€” don't.
        return tenantFromHost(req.getHeader("Host")) != null;
    }

    @Override
    public void handle(ServiceRequest<?> req, ServiceResponse<?> res) {
        var tenant = tenantFromHost(req.getHeader("Host"));

        var conf = mclient.getDatabase("confs")
                .getCollection("stripe", BsonDocument.class)
                .find(eq("_id", tenant)).first();

        if (conf == null) {
            return;   // tenant never installed the plugin β€” falls back to the static config
        }

        req.attachParam("override-stripe-secret-key",     conf.getString("secret_key").getValue());
        req.attachParam("override-stripe-webhook-secret", conf.getString("webhook_secret").getValue());
        req.attachParam("override-stripe-db",             tenant);

        var plans = new java.util.LinkedHashMap<String, PlanConfig>();
        for (var entry : conf.getDocument("plans").entrySet()) {
            // toMap(): your own BsonDocument -> Map<String,Object> conversion (e.g. via the
            // driver's Document codec). PlanConfig.fromMap() itself doesn't care where the
            // map came from β€” see "Overriding the plan catalog" above.
            plans.put(entry.getKey(), PlanConfig.fromMap(toMap(entry.getValue().asDocument())));
        }
        req.attachParam("override-stripe-plans", plans);
    }
}
Note

REQUEST_BEFORE_EXCHANGE_INIT is required, not merely convenient: it is the only intercept point that runs before authentication and before the service reads any configuration.

Attaching overrides later means the webhook signature has already been verified against the wrong secret.

Testing tenant isolation

Two checks are worth automating, because both failures are silent:

  1. State isolation β€” a webhook for tenant A must not modify tenant B’s entity, even when both have an entity with the same id in different databases.

  2. Key isolation β€” under concurrent load from two tenants, every Stripe call must carry its own tenant’s key. This is the regression test for the global-apiKey problem; without it, a future refactor can reintroduce it and nothing will fail visibly until a customer is billed in the wrong account.