Invite a coach to the team
const url = 'http://localhost:8000/api/team/invites/';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"email":"newcoach@club.example","display_name":"New Coach"}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url http://localhost:8000/api/team/invites/ \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "email": "newcoach@club.example", "display_name": "New Coach" }'Owner-only. Creates a pending invite, sends a transactional email with a signed accept link, and returns the Invite row. The token in the email is single-use; calling this endpoint twice for the same email cancels the previous pending invite first so the old link stops working.
Returns 400 if the email is your own, 409 if that email already has direct membership on the team, 403 if the caller isn’t the owner. Inviting someone who already belongs to OTHER teams is fine — they’ll get a second membership on accept rather than being moved.
402 — seat_limit_reached when the team has already filled plan.max_seats. Free’s (1) cap makes this fire on the very first invite attempt; Starter ditto. The accept-side path (POST /api/invites/<token>/accept/) re-checks the same gate as defence-in-depth.
Authorizations
Section titled “Authorizations”Request Bodyrequired
Section titled “Request Bodyrequired”object
Email address to send the invite to. Single-use — re-inviting the same email cancels the previous pending invite first.
Optional friendly name to use in the invite email’s salutation. Defaults to empty.
Examples
Invite a coach
{ "email": "newcoach@club.example", "display_name": "New Coach"}object
Email address to send the invite to. Single-use — re-inviting the same email cancels the previous pending invite first.
Optional friendly name to use in the invite email’s salutation. Defaults to empty.
Examplegenerated
email=hello%40example.com&display_name=exampleobject
Email address to send the invite to. Single-use — re-inviting the same email cancels the previous pending invite first.
Optional friendly name to use in the invite email’s salutation. Defaults to empty.
Responses
Section titled “Responses”Full invite row — owner-facing (pending invites list).
object
Invite UUID — use to cancel via DELETE /api/team/invites/
Recipient email.
Friendly name as supplied at invite creation (optional).
pending / accepted / cancelled / expired.
pending- Pendingaccepted- Acceptedexpired- Expiredcancelled- Cancelled
ISO-8601 UTC. Invites expire after the team-wide TTL (currently 7 days).
Stamped when the recipient accepts. Null otherwise.
When the invite was sent.
Examples
Invite created
{ "id": "9b1c7e2a-4d3f-4a8b-9c0d-1e2f3a4b5c6d", "email": "newcoach@club.example", "display_name": "New Coach", "status": "pending", "expires_at": "2026-06-10T00:00:00Z", "created_at": "2026-05-27T09:00:00Z"}object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Examples
400 — Inviting yourself
{ "detail": "You're already the team owner — can't invite yourself."}object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Examplegenerated
{ "detail": "example"}object
One of five canonical strings. The front-end paywall switches copy + CTA on this value (exact string equality — do not localise). quota_exhausted / seat_limit_reached / client_limit_reached / feature_not_in_plan lead to an upgrade CTA; subscription_suspended leads to a Billing Portal CTA so the buyer can update their payment method.
quota_exhausted- Session quota for the period reached 0subscription_suspended- Stripe dunning in flight (past_due / unpaid)seat_limit_reached- Adding another team member would exceed plan.max_seatsclient_limit_reached- Adding another Client would exceed plan.max_client_profilesfeature_not_in_plan- The tier does not include this whole feature surface
ISO-8601 UTC timestamp the team’s current period ends. Null when the team has no live subscription (legacy un-migrated row). On Free + paid this is the renewal moment; quota refreshes to exactly plan.session_quota (NOT additive — leftover sessions don’t roll over).
The active plan’s role string (free, starter, professional, organization, strategic). Same value as current_role — kept as two separate fields for forward-compatibility with a future named-plan split where current_plan could carry an SKU and current_role the tier label.
The active plan’s role string. See current_plan.
Which feature surface is locked. Present ONLY on feature_not_in_plan, so one modal can name what’s gated without a detail string per feature (#773). The only value today is agents (aikynetix/agents/services.py::AGENTS_FEATURE).
Examples
Free team's `max_seats=1` is already the owner
{ "detail": "seat_limit_reached", "reset_at": "2026-06-01T00:00:00Z", "current_plan": "free", "current_role": "free"}object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Examplegenerated
{ "detail": "example"}object
409 — the request conflicts with current state. detail is a stable code (e.g. reanalyze_in_flight, already_on_plan, email_in_use). Some 409s carry extra context fields alongside detail; those are documented on the specific endpoint that emits them.
Examples
409 — Email already on the team
{ "detail": "This email is already on the team."}