Multi-tenancy
RESTHeartA 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 |
|---|---|---|
|
|
The Stripe account this request bills against |
|
|
Signing secret used to verify this request’s webhook signature |
|
|
Replaces the entire catalog β see below |
|
|
Plan for an entity with no subscription |
|
|
Database holding this tenant’s entity documents |
|
|
Collection holding them |
|
|
Per-tenant Checkout success redirect |
|
|
Per-tenant Checkout cancel redirect |
|
|
Per-tenant Portal return URL |
|
built-in / configured template |
Inline HTML for one notification template |
|
|
Enables or disables one notification |
|
|
Which team role may manage billing. Shared with |
{name} is one of payment-failed, trial-will-end, subscription-canceled, over-limit.
Per-tenant Stripe accounts
|
Important
|
The module never assigns the API key globally. The Stripe Java SDK exposes a global
|
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);
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 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
|
A deployment whose database varies per tenant must create that index on every tenant database itself.
Without it, |
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) {
return req.getPath().startsWith("/stripe/");
}
@Override
public void handle(ServiceRequest<?> req, ServiceResponse<?> res) {
var tenant = tenantFromHost(req.getHeader("Host"));
if (tenant == null) {
return; // fall back to the static configuration
}
var conf = mclient.getDatabase("confs")
.getCollection("stripe", BsonDocument.class)
.find(eq("_id", tenant)).first();
if (conf == null) {
return;
}
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);
req.attachParam("override-stripe-plans", parsePlans(conf.getDocument("plans")));
}
}
|
Note
|
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:
-
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.
-
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-
apiKeyproblem; without it, a future refactor can reintroduce it and nothing will fail visibly until a customer is billed in the wrong account.