Tenant Provisioning
Provisioning a design partner means creating a tenant, putting somebody in it
who can sign in, and being able to prove both afterwards. This runbook does that
against identity, which is the only service that owns tenancy.
Every response body and status code in this runbook is quoted from a real
session against identity with a real Postgres behind it. Where a step’s
output is worth checking by eye, the check is the step.
What you need
Section titled “What you need”identityrunning withDATABASE_URLset. The/v1routes are only registered when it is, so an unset database is a clean404on every endpoint below rather than an authentication error.curl. Nothing else.- The customer’s account name (1–120 characters) and their first user’s email address. The slug is derived from the name; you do not choose it.
Before you start: what “provisioned” means here
Section titled “Before you start: what “provisioned” means here”Registration creates two things in one transaction: a users row and a
personal account for that user, plus an owner membership. So a registered
user already has an account — one nobody chose, named after them, with a slug
like ada-8a2d614900000000.
A design partner’s tenant is the second account: a deliberate, non-personal one with a slug somebody can put in a hostname. Both exist. That is the platform’s shape, not an accident of this procedure, and a customer with both accounts is normal.
Step 1 — set the base URL and check the service is ready
Section titled “Step 1 — set the base URL and check the service is ready”export B=http://localhost:8080curl -s "$B/readyz"{"status":"ok","deps":"postgres"}deps: "postgres" is the one that matters. deps: "none" means DATABASE_URL
is unset — there is no pool, no readiness dependency, and no /v1 routes at
all. That is a 404 on every call in this runbook, and it is the single most
common reason provisioning appears to do nothing.
Step 2 — create the customer’s first user
Section titled “Step 2 — create the customer’s first user”curl -s -o user.json -w 'HTTP %{http_code}\n' \ -X POST "$B/v1/users" \ -H 'Content-Type: application/json' \ -d '{"email":"ada@acme.example","password":"correct-horse-battery-staple"}'cat user.jsonHTTP 201{"id":"8a2d6149-374d-4874-899a-59eb6b999c9e","email":"ada@acme.example"}The password is the customer’s to choose. Password rules are enforced here and
refused with a 422 that names the field:
HTTP 422{"status":422,"title":"Validation failed","detail":"the request has an invalid field", "code":"validation_failed","trace_id":"9f8b94db-…","errors":[{"field":"password","code":"too_short"}]}A taken address is a 409, and it says so without confirming the account:
HTTP 409{"status":409,"title":"Conflict","detail":"an account already exists for that email address", "code":"conflict","trace_id":"c774dbc1-…"}Store the id. Every later step keys on it.
Step 3 — authenticate as them and hold the token
Section titled “Step 3 — authenticate as them and hold the token”curl -s -o session.json -w 'HTTP %{http_code}\n' \ -X POST "$B/v1/session" \ -H 'Content-Type: application/json' \ -d '{"email":"ada@acme.example","password":"correct-horse-battery-staple"}'
export TOKEN=$(python3 -c 'import json;print(json.load(open("session.json"))["token"])'){"token":"iV63Rk7vDTft3yAh0pFlKvCfh-lB-33Uk_Bv0AGBLig","expires_at":"2026-10-30T07:51:38.919827Z"}POST /v1/session also sets a __Host-session cookie to the same token. For a
script, the bearer header is what you want; the cookie exists for the browser.
Every unusable credential gets one sentence and one status. A wrong password, an unknown address, and a locked account are deliberately indistinguishable in their wording, so this endpoint cannot be used to enumerate who has an account:
HTTP 401{"status":401,"title":"Unauthorized","detail":"authentication is required to access this resource", "code":"unauthorized","trace_id":"…"}A locked account is 423 account_locked with a Retry-After header — the
one credential failure that does say something, because a client must be able to
back off.
Step 4 — confirm the personal account exists
Section titled “Step 4 — confirm the personal account exists”curl -s "$B/v1/me" -H "Authorization: Bearer $TOKEN"curl -s "$B/v1/accounts" -H "Authorization: Bearer $TOKEN"[{"id":"c65b1a68-19d0-4866-b0d8-505fb5d4451f", "name":"ada","slug":"ada-8a2d614900000000","personal":true,"role":"owner", "created_at":"2026-09-30T10:51:38.752309+03:00"}]personal: true and role: owner. This is the free-floating account. You are
about to make the real one.
Step 5 — create the tenant
Section titled “Step 5 — create the tenant”curl -s -o account.json -w 'HTTP %{http_code}\n' \ -X POST "$B/v1/accounts" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"name":"Acme Corp"}'
export ACC=$(python3 -c 'import json;print(json.load(open("account.json"))["id"])')HTTP 201{"id":"8f80905d-a8d1-470f-aa01-84fc79d81e4b","name":"Acme Corp","slug":"acme-corp", "personal":false,"role":"owner","created_at":"…","updated_at":"…"}Three things to read off that response.
personal: false. This is a team account, deliberately created. A row
nobody asked for is a team account, and team is the ordinary case.
role: "owner". The owner is the authenticated user, never anything from
the request body. There is no version of this body that creates an account owned
by somebody else, and that is the line between “create an account I own” and
“create an account owned by a third party”.
slug: "acme-corp". Lower-case ASCII alphanumerics separated by single
dashes, one DNS label, at most 63 characters. It is derived from the name at
creation and never changes — renaming the account does not move the handle,
and the rename response returns the original slug so a client can see that.
A collision is a 409 and it is deliberate. Two companies both wanting “acme”
get the second one a refusal rather than a silent -2 nobody chose:
HTTP 409{"status":409,"title":"Conflict", "detail":"an account with that name already exists; its handle is the name in lower case with dashes", "code":"conflict","trace_id":"fcc2afc1-…"}Pick a different name. There is no admin override and no slug parameter.
Step 6 — invite the rest of the team
Section titled “Step 6 — invite the rest of the team”curl -s -o invite.json -w 'HTTP %{http_code}\n' \ -X POST "$B/v1/accounts/$ACC/invitations" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"email":"bob@acme.example","role":"member"}'HTTP 201{"id":"61c58dfa-83ff-4b41-aa88-89a78e0d5c7c", "account_id":"8f80905d-a8d1-470f-aa01-84fc79d81e4b", "email":"bob@acme.example","role":"member", "token":"BQq4_R07yGbMkxRoc55Y6rL0vE2l9WtcnPW-BP5EGdg", "expires_at":"2026-10-07T10:51:50.606107+03:00","created_at":"…"}The token is in the response body, and it is still yours to deliver. This
is the step people expect to be automated and is not, and the reason has moved
twice — so here is where the chain actually stops today.
courier can send mail. It used to render messages into memory and return a
provider-shaped id without opening a socket, which meant a released courier
accepted every send, wrote an outbox row, published courier.email.delivered,
and mailed nobody. That is fixed: COURIER_MAIL_ADAPTER=smtp with
COURIER_SMTP_HOST and the rest is a real relay, and a courier with no adapter
configured now refuses to boot rather than silently discarding what it is
handed.
identity talks to courier — for recovery, not invitations. It holds a
client for POST /v1/messages and sends password_reset and welcome. It
deliberately refuses two messages, because a wrong-but-delivered security notice
is worse than a loud failure: both halves of an email change are refused, since
mapping them onto password_reset would put “somebody asked to reset the
password on your account” into the one message a hijacked session cannot get
past.
But nobody sends team_invitation. courier has that template and
identity’s client can populate it, and identity still does not call it — the
constant exists, with a comment saying so, so the drift test has something to
compare against. So the gap is not the mail system and not the template — it is
one unmade call, and nothing in the platform delivers events off a bus to make
it for you.
If you have configured courier with an SMTP adapter, that still does not
send this. Two independent reasons, both worth stating so nobody assumes
configuring the adapter fixed the step: courier’s adapter is chosen by
COURIER_MAIL_ADAPTER and courier refuses to start in production without
one, and configuring it does not create a caller. The invitation token is in
the response body above; that response is the delivery mechanism. See rotating
secrets
for what the adapter does and does not switch on.
So: deliver the token yourself — out of band, to the address you just
invited, by a channel you trust. Read it out of the 201, because
account_invitations stores token_digest and there is no column that could
hold the raw token afterwards — a database dump yields no usable invitation.
expires_at is seven days out. An expired invitation is 410, and a used one
is 410; both mean “ask for a new one”, and re-posting to /invitations is the
fix. A wrong token is 404 with the same sentence an unknown account would get,
so a guessed token is useless.
role may only be admin or member. owner is not invitable — an
existing owner promotes a member to owner after they have joined. Inviting
owner is a 422 naming the field.
Step 7 — the invitee joins
Section titled “Step 7 — the invitee joins”They register and log in like anybody else — there is no invite-only signup path, because the invitation does not create the user:
curl -s -X POST "$B/v1/session" -H 'Content-Type: application/json' \ -d '{"email":"bob@acme.example","password":"<their password>"}' -o bsession.jsonexport BTOKEN=$(python3 -c 'import json;print(json.load(open("bsession.json"))["token"])')
curl -s -o accept.json -w 'HTTP %{http_code}\n' \ -X POST "$B/v1/invitations/accept" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $BTOKEN" \ -d '{"token":"<the token from step 6>"}'The 200 body carries the membership’s user_id, so keep it — step 8 needs
it and this is the only response that has it:
export BOB=$(python3 -c 'import json;print(json.load(open("accept.json"))["user_id"])')HTTP 200{"account_id":"8f80905d-a8d1-470f-aa01-84fc79d81e4b", "user_id":"d9bfa571-01f5-4701-9cb6-efeece5c782d", "role":"member","created_at":"2026-09-30T10:51:58.202431+03:00"}There is no account in the path: the token names the account. The caller still has to be authenticated, because the membership being created is theirs.
Step 8 — promote somebody to owner, if you need a second one
Section titled “Step 8 — promote somebody to owner, if you need a second one”curl -s -X PATCH "$B/v1/accounts/$ACC/members/$BOB" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"role":"owner"}'This is owner-only. An admin or a member attempting it gets 403 your role in this account does not permit this action — the route’s minimum role is
owner, checked before the handler runs.
Roles, and the two rules that protect the account
Section titled “Roles, and the two rules that protect the account”Roles are owner, admin, member. They are a PostgreSQL enum, ordered, and
the ordering is what the minimum-role check compares.
| Operation | Minimum role |
|---|---|
| Read the account, list members | member |
| Rename, invite, remove a member | admin |
| Change a role, delete the account | owner |
Two refusals are worth knowing before you hit them.
The last owner is never removed. An owner demoting themselves while they are the only owner gets:
HTTP 422{"status":422,"title":"Validation failed", "detail":"you are the only owner of this account and cannot change your own role", "code":"validation_failed","errors":[{"field":"role","code":"last_owner"}]}Promote somebody else to owner first, then step down. Verified: with two
owners, the demotion succeeds.
A non-member gets 404, never 403. Reading an account you are not a
member of:
HTTP 404{"status":404,"title":"Not found","detail":"no such account is visible to you", "code":"not_found","trace_id":"…"}That is deliberate and it is a security property, not a formatting choice. A
403 would tell any authenticated caller that the account id they guessed is
real, which is a free tenant-enumeration oracle on a table whose ids are the
only thing between one customer and another. So the two denials are kept apart:
404 when the resource is not yours to know about, 403 when it is and you
lack the permission. A 404 on an account you believe exists is usually a
membership problem, not a missing account. Check the membership.
Step 9 — verify the tenant
Section titled “Step 9 — verify the tenant”Verification is a separate step from provisioning. Applying the changes is not evidence that they worked; reading them back is.
# 1. the account, as its ownercurl -s "$B/v1/accounts/$ACC" -H "Authorization: Bearer $TOKEN"
# 2. the new member can read it (role: member is enough)curl -s "$B/v1/accounts/$ACC" -H "Authorization: Bearer $BTOKEN"
# 3. a complete outsider cannot — and gets 404, not 403curl -s -o /dev/null -w '%{http_code}\n' "$B/v1/accounts/$ACC" -H "Authorization: Bearer $SOMEONE_ELSE"Steps 1 and 2 must both be 200. Step 3 must be 404. If step 2 is 404, the
invitation was never accepted.
Also verify the events were recorded
Section titled “Also verify the events were recorded”identity writes its outbox in the same transaction as the domain change, so a
provisioned tenant leaves rows behind:
psql "$DATABASE_URL" -c \ "select type, subject, published_at is null as unpublished, attempts from outbox_events order by occurred_at desc limit 10;" type | subject | unpublished | attempts--------------------------+--------------------------------------+-------------+----------identity.member.accepted | 8f80905d-a8d1-470f-aa01-84fc79d81e4b | t | 0identity.account.created | 8f80905d-a8d1-470f-aa01-84fc79d81e4b | t | 0identity.user.created | d9bfa571-01f5-4701-9cb6-efeece5c782d | t | 0unpublished: t on every row is expected and not a fault. No service starts
a publisher loop; identity’s only Publisher implementation is a deliberate
no-op. So a tenant is provisioned and its events are recorded, and nothing is
delivered to anyone. Do not go looking for a broker, and do not “fix” this by
starting the loop — that marks events published and drains the outbox into
nowhere.
The DATABASE_URL above is the one identity reads. psql takes the same
DSN, which is the one convenience of database-per-service worth having.
What does not happen automatically
Section titled “What does not happen automatically”| Step | Why not | What to do |
|---|---|---|
| The invitation email | courier can send and identity can call it, but nobody sends team_invitation — one unmade call, not a missing template and not a missing adapter |
Send the token yourself, out of band, from the 201 |
| A billing customer | billing is a separate deployment with its own database |
Create plans and customers through /v1 once you need them — billing |
A gateway role mapping |
gateway verifies tokens; role enforcement is identity’s |
Nothing. /v1/* is bearer-only and identity is the authority |
| Any event delivery | No publisher loop runs | Build the consumer yourself, or read the outbox |
Member identifiers from GET …/members |
Only role is populated; account_id, user_id and created_at come back empty |
Use step 2 of step 9 — the new member’s own token reading the account |
Rolling it back
Section titled “Rolling it back”DELETE /v1/accounts/{accountID} is owner-only and removes the account and
everything scoped by it. There is no undo and no soft delete.
Before you run it, take a backup. The backup and restore runbook is the procedure, and restoring a single tenant out of a fresh dump is a query, not a restore.
Do not delete the tenant because a member is misconfigured. Membership is correctable in place: change the role, or remove the membership. Deleting the account to fix a permissions problem destroys every row scoped by it.
See also
Section titled “See also”- Topology — the routes, roles, and ports this runbook uses.
- A service is down — if step 1’s
readyzis 503. - Rotating secrets — the customer’s credentials.