Provisioning
What the platform provisions when a customer subscribes, the call your app receives, and what your handlers must do on your end — with worked examples.
Provisioning connects a subscription to a running tenant
When a customer subscribes to a plan that includes your app, the Control Plane turns that subscription into a live tenant instance — infrastructure allocated, credentials stored, roles seeded, and your app told to get ready. Almost all of that is the platform's work. Your app participates at exactly one point: the Control Plane calls a lifecycle endpoint on your service, and your handler prepares the instance (runs migrations, seeds baseline structure) and acknowledges.
This page is the full contract for that connection: what the platform provisions, the call your app receives, what your handler must do, and what happens when things fail. If you implement nothing else from this page, implement the two handlers — an app without them cannot be provisioned at all.
What the platform runs when a customer subscribes
Provisioning is a per-instance pipeline the Control Plane drives step by step, retrying failed steps with backoff before marking the instance failed. You'll see these stages in the operator UI, and it helps to know where your app sits in the order:
| Stage | Who does the work | What happens |
|---|---|---|
| Allocate | Platform | Chooses the region and the pooled or dedicated resources your manifest's spec.tenancy asks for |
| Provision infrastructure | Platform | Creates the tenant's resources — for per_instance_db apps, this is where the database is created |
| Store credentials | Platform | Writes the connection details and secrets into the instance's config, where your app will read them |
| Sync authorization | Platform | Seeds your manifest's portal roles and permissions into the Authorization service, scoped to this org + instance |
| Notify app | Your app | CP calls your provision lifecycle endpoint; your handler readies the instance and acknowledges |
| Mark ready | Platform | Promotes the instance — only after your acknowledgement |
| Register routing | Platform | Publishes the instance so requests route to it |
| Notify customer | Platform | Tells the subscribing user their instance is ready |
Two consequences of that order worth internalizing:
- When your handler runs, everything you need already exists. The database (if you asked for one) has been created and its credentials stored. Your job is what only you can do: the schema, the seed rows, your app's notion of an empty-but-working tenant.
- The instance is not ready until you say so. A failing or missing handler doesn't produce a degraded instance — it produces a failed one. Provisioning is fail-closed.
The division of labour
The line between platform work and app work is deliberate and stable:
| The platform provisions | Your app does on its end |
|---|---|
| The instance record, subdomain and routing | Implements the provision / deprovision lifecycle handlers |
Infrastructure per your spec.tenancy choice — a pooled reference or a dedicated database | Creates its own schema and baseline structure inside what the platform created |
| Credentials, injected into instance config (your handler reads them from CP — never from local state) | Seeds required rows: defaults, empty containers, whatever "new tenant, nothing in it yet" means for your domain |
| Portal roles and permissions from your manifest, seeded into the Authorization service | Acknowledges with a 200 so the pipeline can promote the instance |
Per-org environment resolved from spec.required_env | Cleans up tenant data at deprovision, per your retention policy |
The tenancy choice itself is declared in your manifest — spec.tenancy.storage: per_instance_db (the platform creates a database per tenant instance), per_org_db (one database per organization per region) or shared_with_filter (one shared database, every query scoped; still accepted, not the direction for new data). See Tenancy & infrastructure for the full isolation model and Data placement for where each table lives.
The call your app receives
You do not declare this endpoint — the SDK mounts it for you at a conventional path, and CP constructs the URL the same way for every app:
POST {your_base_url}/v1/{app_code}/internal/lifecycle/provision
x-api-key: {the Control Plane's caller key}
content-type: application/jsonIt's an internal-tier call (x-api-key, the same gate CP uses for all service-to-service traffic — see Auth & the five gates). The Control Plane presents its own caller key, and your app admits it only if your SERVICE_API_KEYS lists it under the code control-plane; otherwise the call gets 401 and the provision step fails. The body is small and stable:
{
"app_code": "my-saas",
"org_guid": "0d9f3c1e-…",
"tenant_application_instance_guid": "7b42a8d0-…",
"subscription_guid": "c91e5f77-…"
}tenant_application_instance_guid is the one required field — it names the instance being provisioned and is the key you'll use to fetch that instance's config from CP. Everything your handler needs beyond this payload, it fetches from the Control Plane.
Your handler returns whatever result dictionary is useful for operators, and the SDK wraps it in the standard envelope:
{
"success": true,
"status": "ok",
"event": "provision",
"request_id": "d1f0…",
"result": { "ready": true, "model": "shared_db" }
}Implement the handlers
The SDK gives you a LifecycleRegistry: register async handlers per event, hand the registry to create_saas_app, and the lifecycle router — auth gate, validation, timeouts, response envelopes — is mounted automatically. Register provision and deprovision at minimum, even if they're acknowledgements with no work to do.
Here is the complete wiring for a shared-database app, where the platform's provisioning already did everything and the handler simply acknowledges:
from egav_app_sdk import (
LifecycleContext, LifecycleRegistry, create_saas_app, load_manifest,
)
MANIFEST = load_manifest("backend/app.manifest.yaml")
lifecycle = LifecycleRegistry()
@lifecycle.on("provision")
async def on_provision(ctx: LifecycleContext, payload: dict) -> dict:
# Shared-DB model: rows are scoped per instance at query time, so a new
# tenant needs no storage work. Acknowledge so CP can mark the instance ready.
return {"ready": True, "model": "shared_db",
"instance": ctx.tenant_application_instance_guid}
@lifecycle.on("deprovision")
async def on_deprovision(ctx: LifecycleContext, payload: dict) -> dict:
# Delete or archive this instance's rows per your retention policy.
await purge_instance_data(ctx.tenant_application_instance_guid)
return {"cleaned": True}
app = create_saas_app(
manifest=MANIFEST,
settings=Settings(),
instance_resolver=instance_resolver,
routers={"portal": portal},
lifecycle_handlers=lifecycle, # ← mounts /v1/{app_code}/internal/lifecycle/*
)The handler signature is always async (ctx, payload) -> dict | None. LifecycleContext carries the parsed identifiers — event, app_code, org_guid, tenant_application_instance_guid, subscription_guid, request_id — so you rarely touch the raw payload.
If your service is built from the egav-template scaffold (it builds its app with egav_base.create_app, not create_saas_app), mount the same router yourself:
from egav_app_sdk import LifecycleRegistry
from egav_app_sdk.lifecycle.endpoints import make_lifecycle_router
lifecycle = LifecycleRegistry() # register provision / deprovision as above
app = create_app(config=config, api_router=api_router, health_router=health_router)
app.include_router(make_lifecycle_router("my-saas", lifecycle)) # your metadata.nameWhy the no-op matters. If your app doesn't mount the lifecycle router, CP's call gets a 404 and the provision step fails — the instance ends up failed, not "provisioned with a warning". The cheapest correct app registers both handlers on day one, even as acknowledgements.
The per-instance-database example
For a per_instance_db app, the provision handler does real work: find the database the platform created for this instance, then build your schema in it. egav_base resolves the database for you. Set its tenant session factory up once at startup:
import os
from egav_base import (
ServiceConfigDsnResolver,
boto_secret_endpoint_resolver,
cp_service_config_getter,
init_async_tenant_session_factory,
)
init_async_tenant_session_factory(
ServiceConfigDsnResolver(
app_code="my-app",
# Reads the instance's database connection from the Control Plane:
# a secret reference in production, a plain DSN in local dev.
service_config_getter=cp_service_config_getter(
control_plane_base_url=os.environ["CONTROL_PLANE_BASE_URL"],
api_key=os.environ["CONTROL_PLANE_API_KEY"],
verify_ssl=os.environ.get("CONTROL_PLANE_VERIFY_SSL", "true").lower() not in {"0", "false", "no"},
),
secret_endpoint_resolver=boto_secret_endpoint_resolver(
endpoint_url=os.environ.get("SECRETSMANAGER_ENDPOINT_URL"),
),
),
pool_settings_app_code="my-app",
)Then the lifecycle handlers ask the factory for one instance's database:
import asyncio
from egav_app_sdk import LifecycleContext, LifecycleRegistry
from egav_base import get_async_tenant_session_factory
lifecycle = LifecycleRegistry()
@lifecycle.on("provision")
async def on_provision(ctx: LifecycleContext, payload: dict) -> dict:
# 1. The database the platform created for this instance.
endpoints = await get_async_tenant_session_factory().async_endpoints_for_instance(
ctx.tenant_application_instance_guid
)
# 2. The schema is yours: run your migrations against it.
# run_alembic_upgrade is your own wrapper around `alembic upgrade`.
await asyncio.to_thread(run_alembic_upgrade, endpoints.write, "head")
# 3. Seed whatever "empty but working" means for your domain (your code).
async with get_async_tenant_session_factory().session(ctx.tenant_application_instance_guid) as db:
await seed_baseline(db, org_guid=ctx.org_guid)
await db.commit()
return {"ready": True, "migrated_to": "head"}
@lifecycle.on("deprovision")
async def on_deprovision(ctx: LifecycleContext, payload: dict) -> dict:
# Remove or retain the instance's data per your retention policy (your code).
await retire_instance_data(ctx.tenant_application_instance_guid)
return {"retired": True}Request handlers get the same database with the async_tenant_db_session dependency (db: AsyncSession = Depends(async_tenant_db_session)), which picks the instance from the request's X-App-Instance-Guid header.
Three rules that keep this handler correct under real conditions:
- Read credentials from the Control Plane, never from local assumptions. The instance's
service_configis the single source of the tenant DSN. In production it's a secret reference your process resolves; in local dev it's a plain DSN. Your handler should support both by resolving through the config, not by branching on environment. - If the config isn't there yet, fail with a 5xx — don't guess. Provisioning steps can race; if the credentials slice hasn't landed when your handler runs, returning an error is correct: CP retries with backoff and your handler succeeds on a later attempt.
- Blocking work goes off the event loop. Migrations are synchronous — wrap them (
asyncio.to_thread) so your service keeps serving traffic while a tenant provisions.
Idempotency is not optional
Every handler must tolerate being called more than once for the same instance, because retries are how the pipeline heals:
- CP retries a failed or timed-out provision call with backoff before giving up. Your handler will eventually be re-invoked after a partial failure.
- CP waits a bounded time per attempt (on the order of tens of seconds). A provision that legitimately takes minutes will be re-called while the first run's effects are already partially in place.
- Operators can re-fire lifecycle phases manually as a recovery action.
So: migrations that converge (upgrade head is naturally idempotent), seed logic that checks before inserting, teardown that treats "already gone" as success (DROP … IF EXISTS). A handler that only works on a pristine first call will fail exactly when it matters — during recovery.
Status codes, timeouts, and how failures are classified
The SDK receiver returns a precise status per failure mode, and CP acts on it:
| Your response | Meaning | What CP does |
|---|---|---|
200 | Handler succeeded | Advances the pipeline; instance is promoted |
401 | Missing/wrong x-api-key | Fails the attempt — fix the key configuration |
422 | Payload missing tenant_application_instance_guid | Fails the attempt |
501 (error_code 5010) | No handler registered for this event | Distinguishes "not implemented" from "failed" |
504 (error_code 5040) | Handler exceeded its time budget | Retries — your next run must converge |
500 (error_code 5001) | Handler raised | Retries on the assumption it's transient |
CP's classification is simple: 5xx and timeouts are transient (retried with backoff, a few attempts before the instance is marked failed); 4xx is a hard failure (no retry — the config or contract is wrong). Error messages you return are persisted on the instance's provisioning record and shown to operators — return actionable ones, not stack traces.
Each event also has a receiver-side time budget — generous for the events that do real work:
| Event | Budget |
|---|---|
provision | 15 minutes |
deprovision | 10 minutes |
reload_config | 30 seconds |
suspend / resume | 60 seconds |
The budget is a hard stop on your handler, not a promise that CP waits that long per attempt — which is exactly why the idempotency section above exists.
The platform dispatches more than the provisioning pair. provision and deprovision are pipeline steps; suspend and resume follow real changes to an instance's suspension state. An operator's suspend/unsuspend action queues the corresponding delivery for that instance. Billing enforcement queues suspend after the subscription's grace period and resume when a billing-suspended instance is reactivated, either on the payment/reactivation path or during the daily reconciliation sweep. reload_config remains part of the receiver contract, but is not part of those suspension flows.
For suspend and resume, the Control Plane's instance state is authoritative. Every operator or billing path commits the state change and a per-instance delivery intent atomically. A Control Plane PlatformJob worker then invalidates the distributed suspension cache and resolves the lifecycle endpoint in that instance's organization context before making the call. The API-visible state changes as soon as the transaction commits; cache and callback convergence may follow shortly afterward.
Delivery is durable and at least once. A stable request identity is reused when the worker retries the same transition, duplicate enqueue attempts converge, and suspend/resume intents share a per-instance FIFO lane so they cannot be delivered out of order. Worker leases are fenced; failures follow the platform's retry and parking policy, with operator alerts when delivery does not converge. Your handler must therefore be idempotent for the same instance and request identity. Treat it as a prompt to pause or restart app-owned work, not as the source of truth for access. Billing enforcement also emits cp.subscription.lifecycle_enforced for subscribers — see Billing lifecycle.
Deprovisioning is fail-closed
Teardown runs the same connection in reverse, with one deliberate difference in strictness: when an instance is deprovisioned, CP calls your app's cleanup and refuses to complete the teardown until it succeeds. A failed cleanup leaves the instance live and retryable rather than stranding tenant data in storage that nothing routes to anymore. The deprovision payload also carries a reason (user_requested, subscription_cancelled, plan_downgrade, org_deleted, operator, compliance) if your retention policy distinguishes them.
When deprovision is called. Neither of the two customer-facing endings calls it straight away, so do not expect the call within minutes:
| What happened | When your app's deprovision runs |
|---|---|
| The subscription is cancelled | Not on cancellation. The billing timeline suspends the instance after 14 days and deprovisions it after 90, and only if destruction is enabled (off by default). See Billing lifecycle. |
| The organization is deleted | After the restore window, 30 days by default (legal.data_retention.org_deletion.grace_days). The organization's data is erased first: every app declaring spec.gdpr.org_erasure receives the erasure call, and the instances are deprovisioned (reason: org_deleted) once all of them have erased. An operator who deletes with immediate erasure skips the window. Restoring the organization inside the window cancels both. |
| An operator deletes one instance | At once. |
Your app can also declare additional teardown participants in spec.lifecycle_hooks.on_deprovision — including delegating to another app (app://<target>/<path>) when cleanup spans services. See Hooks for the declaration syntax.
After provisioning
Once your handler acknowledges and the pipeline completes:
- Your manifest's roles are live in the Authorization service for this org + instance — the subscribing admin can immediately grant them. You did nothing for this; it's the sync step, driven by the
portal_rolesyou declared. See Authorization. - Requests arrive with the instance identity. Every portal call to your backend carries
X-App-Instance-Guid; the SDK resolves it into yourAppContextso queries scope to the right tenant. See Tenancy & infrastructure. - The customer is notified and lands in a working instance.
Where this fits
- Tenancy & infrastructure — the
spec.tenancyisolation model that decides what gets provisioned per tenant. - Hooks — the declarative
spec.lifecycle_hooksblock: additional endpoints, cross-app delegation, and the phases beyond provision/deprovision. - Quickstart — install the SDK, write a manifest, publish it. Publishing registers your app; provisioning is what happens to it once customers subscribe.
- Auth & the five gates — the internal tier (
x-api-key) that gates every lifecycle call.