Skip to content

Invite a coach to the team

POST
/api/team/invites/
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.

object
email
required

Email address to send the invite to. Single-use — re-inviting the same email cancels the previous pending invite first.

string format: email
>= 1 characters
display_name

Optional friendly name to use in the invite email’s salutation. Defaults to empty.

string
<= 200 characters
Examples
ExampleInviteACoach

Invite a coach

{
"email": "newcoach@club.example",
"display_name": "New Coach"
}
Media typeapplication/json

Full invite row — owner-facing (pending invites list).

object
id
required

Invite UUID — use to cancel via DELETE /api/team/invites//.

string format: uuid
email
required

Recipient email.

string format: email
display_name
required

Friendly name as supplied at invite creation (optional).

string
status
required

pending / accepted / cancelled / expired.

  • pending - Pending
  • accepted - Accepted
  • expired - Expired
  • cancelled - Cancelled
string
Allowed values: pending accepted expired cancelled
expires_at
required

ISO-8601 UTC. Invites expire after the team-wide TTL (currently 7 days).

string format: date-time
accepted_at
required

Stamped when the recipient accepts. Null otherwise.

string format: date-time
nullable
created_at
required

When the invite was sent.

string format: date-time
Examples
ExampleInviteCreated

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"
}
Media typeapplication/json
object
detail
required

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).

string
Examples
Example400—InvitingYourself

400 — Inviting yourself

{
"detail": "You're already the team owner — can't invite yourself."
}
Media typeapplication/json
object
detail
required

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).

string
Examplegenerated
{
"detail": "example"
}
Media typeapplication/json
object
detail
required

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 0
  • subscription_suspended - Stripe dunning in flight (past_due / unpaid)
  • seat_limit_reached - Adding another team member would exceed plan.max_seats
  • client_limit_reached - Adding another Client would exceed plan.max_client_profiles
  • feature_not_in_plan - The tier does not include this whole feature surface
string
Allowed values: quota_exhausted subscription_suspended seat_limit_reached client_limit_reached feature_not_in_plan
reset_at
required

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).

string format: date-time
nullable
current_plan
required

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.

string
current_role
required

The active plan’s role string. See current_plan.

string
feature

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).

string
Examples
Example402—SeatLimitReachedOnFree

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"
}
Media typeapplication/json
object
detail
required

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).

string
Examplegenerated
{
"detail": "example"
}
Media typeapplication/json
object
detail
required

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.

string
Examples
Example409—EmailAlreadyOnTheTeam

409 — Email already on the team

{
"detail": "This email is already on the team."
}