Skip to Content
Living documentation — last reviewed 2026-05-28
RunbooksOwner invitations & email ownership proof

Owner invitations & email ownership proof

Creating a gym (POST /organizations) requires an owner grant issued by a platform admin. Without one, a signed-in user with no memberships is routed to /{lang}/invitation-required (course buyers go to their /{lang}/library instead). This runbook covers issuing grants, the email-ownership rules that guard them, and rolling out the join-link email-proof flag.

Background: users-auth behavior, onboarding behavior, memberships behavior, admin-app.

Model in one paragraph

A grant is a row in owner_invitations (pending → consumed / revoked / expired, 30-day expiry). One grant creates exactly one organization. The claim happens inside the org-creation transaction (row lock), so a double submit makes the second request fail with 409 OWNER_INVITATION_ALREADY_USED. A retry after a lost response (same user, same gym name, grant already consumed) returns the org that was created. A grant failing before commit stays pending, so the owner can simply retry.

Two ways to grant:

ModeUse whenMechanics
New email (default)The person has no Taikan/Clerk account yet.Admin app creates the grant and Clerk emails an invitation (/sign-up, publicMetadata.ownerInvitationId). The new account claims the grant only if the invitation id and the account’s primary email match.
Existing accountThe person already has an account (e.g. invited from the Clerk Dashboard, or a member of another gym).Grant is bound to their Clerk user id; no email is sent. Their next GET /users/me returns canCreateOrganization: true and /onboarding opens (even for someone who already belongs to another gym).

Invite a new owner

  1. Admin app → Owner Invitations → Invite owner.
  2. Enter the email, leave Existing account off, submit.
  3. The person receives a Clerk invitation email, signs up, and lands on /onboarding.
  4. Check the row: pending until they create the gym, then consumed with the organization shown.

Errors:

ErrorMeaning / action
409 OWNER_INVITATION_EMAIL_HAS_ACCOUNTThat email already has an account. Use Existing account instead (see the proof caveat below).
409 OWNER_INVITATION_PENDING_EXISTSA live pending grant exists. Resend or revoke it.

Resend (row action) re-mints the Clerk invitation and resets the expiry to 30 days; new-email grants only. Revoke cancels a pending grant. Revoking the Clerk invitation from the Clerk Dashboard also revokes the grant (webhook). An expired or revoked grant cannot be revived: create a new one.

Grant an existing account

  1. Admin app → Owner Invitations → Invite owner, enter the account’s email, turn Existing account on.
  2. The user must have a linked account (404 OWNER_INVITATION_ACCOUNT_NOT_FOUND otherwise) and its email must be proven (next section).
  3. Tell them to reload; they are sent to the onboarding sheet.

Backfill: accounts invited from the Clerk Dashboard who have no gym yet

People invited through the Clerk Dashboard before this change have no ownerInvitationId metadata, so after deploy they land on invitation-required. List the candidates:

SELECT u.email, u.clerk_id FROM users u LEFT JOIN memberships m ON m.user_id = u.id WHERE m.id IS NULL AND u.clerk_id IS NOT NULL AND u.deleted_at IS NULL;

For each legitimate person (not stray sign-ups, course-store buyers or test accounts), create an Existing account grant in the admin app. Their accounts carry no joinToken, so they are normally proven and the grant succeeds directly. Open the user in Admin → Users to see the Email Ownership tag.

OWNER_INVITATION_EMAIL_UNPROVEN

An existing-account grant is refused with 409 OWNER_INVITATION_EMAIL_UNPROVEN (“This account has not proven it owns its email…”) when the account is unproven: it was created from a join-link sign-up whose link was handed to the browser (flag OFF), so whoever typed that email could have registered it — including someone pre-registering an address you are about to grant. Also returned when Clerk cannot be read (fail closed).

Handle it:

  1. Confirm out-of-band that the real owner controls that inbox (phone call, reply from a known thread, ask them to forward a code). Do not rely on the sign-up itself.
  2. Admin → Users → open the user → Mark email verified (POST /admin/users/:id/verify-email-ownership). This writes emailProvenAt / emailProvenBy='admin' and emailProvenByActor (your Clerk id) to the Clerk user.
  3. Create the Existing account grant again.

If you cannot confirm the person, do not grant. Delete or ignore the squatted account and issue a new-email grant only once the address has no account (a new-email grant is refused while an account exists: EMAIL_HAS_ACCOUNT). The same Mark email verified action releases a held gym invitation (/users/me.invitationHeld, audit membership.invitation_held) for a legitimate invitee who cannot open the invitation email.

POST /join/:token/request-invite emails a branded, localized sign-up link to the address typed and returns { deliveryMode: 'email', email }; the Clerk URL never reaches the browser. Accounts created from that link are proven. It was rolled out per org behind join-email-ownership-proof, which is retired (never reuse the key).

  • 503 JOIN_EMAIL_DELIVERY_FAILED when the provider does not accept the mail (no URL leaked; the visitor resubmits). Watch join.blocked with reason: 'email_delivery_failed' and the join.invite_emailed count in PostHog, and 503s on request-invite in Sentry.
  • The invite caps (DEFAULT_JOIN_INVITE_CAPS or the join-invite-caps payload) always apply, so the form cannot be used as a mail relay.
  • Accounts created earlier from a browser-delivered ticket stay unproven (member-role join flows are unaffected; only staff-sent invitations to them are held).
  • Held invitations — every staff-sent invitation (any role) is held until the account’s email is proven; only source='join_link' invitations auto-accept for unproven accounts. The signed-in join link refuses to join an unproven account around a held invitation (403 INVITATION_HELD) and never restores a staff role. Opening the invitation email does not release a hold; only Mark email verified does. See incident-response.md.

  • Bulk invite and the Clerk limit — bulk invite can reach every invitable member; a Clerk 429 stops the batch and fails the rest with rate_limited (Clerk allows 100 invitations/hour), shown in the dialog. The audience is bounded by the tier member cap below.

  • Import seat cap — an import that would exceed the tier’s active-member limit is refused whole, with a localized error on the import job. Elite is unlimited.

  • Join link never hands out a ticket for someone else’s identity (not flagged; it closes an account takeover):

    • An address that already has a login gets 409 ACCOUNT_EXISTS and a Sign in button that returns to the link.
    • An address holding a profile someone else created (import, staff invitation, other gym, named guardian) gets its link emailed, with nothing from the form attached. That email counts against the invite caps.
    • New addresses get their link by email too (see above).
    • At claim time, an unproven login can’t take over a row a gym filed after the ticket was minted (403 EMAIL_OWNERSHIP_REQUIRED). Support releases it with Mark email verified.

    See memberships behavior.

Deploy / migration

Migration 0167_solid_shadow_king (owner_invitations table, enum, FK, 3 indexes + partial unique index on pending email) is applied per migrations.md. Deploy order: migration → API → web/admin. Between API and web deploys, new users may briefly be treated as unable to create an organization; nothing else is affected. The owner-grant requirement itself cannot be rolled back without redeploying. If the API deploys before the web, a visitor whose address has a login sees the generic “already a member” screen with a Sign in button (409) rather than the new copy; no ticket is issued either way.