Skip to main content

Documentation

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:

StageWho does the workWhat happens
AllocatePlatformChooses the region and the pooled or dedicated resources your manifest's spec.tenancy asks for
Provision infrastructurePlatformCreates the tenant's resources — for per_instance_db apps, this is where the database is created
Store credentialsPlatformWrites the connection details and secrets into the instance's config, where your app will read them
Sync authorizationPlatformSeeds your manifest's portal roles and permissions into the Authorization service, scoped to this org + instance
Notify appYour appCP calls your provision lifecycle endpoint; your handler readies the instance and acknowledges
Mark readyPlatformPromotes the instance — only after your acknowledgement
Register routingPlatformPublishes the instance so requests route to it
Notify customerPlatformTells 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 provisionsYour app does on its end
The instance record, subdomain and routingImplements the provision / deprovision lifecycle handlers
Infrastructure per your spec.tenancy choice — a pooled reference or a dedicated databaseCreates 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 serviceAcknowledges with a 200 so the pipeline can promote the instance
Per-org environment resolved from spec.required_envCleans 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:

text
POST {your_base_url}/v1/{app_code}/internal/lifecycle/provision
x-api-key: {the Control Plane's caller key}
content-type: application/json

It'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:

json
{
  "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:

json
{
  "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:

backend/main.pypython
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:

backend/app/main.pypython
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.name
Why 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:

backend/app/main.pypython
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:

backend/app/lifecycle.pypython
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_config is 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 responseMeaningWhat CP does
200Handler succeededAdvances the pipeline; instance is promoted
401Missing/wrong x-api-keyFails the attempt — fix the key configuration
422Payload missing tenant_application_instance_guidFails the attempt
501 (error_code 5010)No handler registered for this eventDistinguishes "not implemented" from "failed"
504 (error_code 5040)Handler exceeded its time budgetRetries — your next run must converge
500 (error_code 5001)Handler raisedRetries 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:

EventBudget
provision15 minutes
deprovision10 minutes
reload_config30 seconds
suspend / resume60 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 happenedWhen your app's deprovision runs
The subscription is cancelledNot 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 deletedAfter 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 instanceAt 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_roles you 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 your AppContext so 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.tenancy isolation model that decides what gets provisioned per tenant.
  • Hooks — the declarative spec.lifecycle_hooks block: 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.