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 |
|
|
Replaces the entire products configuration β same replace-wholesale contract as |
|
|
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 |
|
|
Replaces the entire products configuration β same wholesale-replacement contract as |
|
built-in / configured template |
Inline HTML for one notification template β subscription and order notifications alike, see below |
|
|
Enables or disables one subscription notification. Order notifications have no equivalent: their enabled flag travels inside |
|
|
Which team role may manage billing. Shared with |
{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 |
|---|---|
|
Every subscriptions-mode service ( |
|
|
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
|
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 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 calls the same service itself, once per tenant, when that tenant enables the plugin:
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, |
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
|
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.