---
title: "Claim Invitation"
method: POST
path: "/api/v1/teams/invitations/claim"
tags: ["teams"]
---

# Claim Invitation

`POST /api/v1/teams/invitations/claim`

## Request

- Base URL: `https://api.badgeia.com`
- URL: `https://api.badgeia.com/api/v1/teams/invitations/claim`
- Auth: none declared

Bind a presented token to this account so onboarding survives a new tab.

The raw token reaches the browser once, in a URL fragment, and lives in
`sessionStorage`. A first-time invitee must verify their email before they
can accept, and their mail client opens that verification link in a NEW
tab — which shares no `sessionStorage`. So the token was guaranteed to be
gone at the moment it was needed. This records, server-side, that this
account held it, and lets the client drop the token immediately afterwards
rather than parking a live 7-day bearer credential in browser storage.

Deliberately does NOT require `email_verified`: the entire point is to land
the binding BEFORE the invitee leaves for their mailbox. It grants nothing
— `accept_invitation` re-checks verification, account state, email match
and the workspace plan under lock.

Takes no Team lock and refuses nothing on the plan — a claim is a binding
rather than a grant, so it needs neither, which keeps it out of the Team →
Invitation ordering the rest of this file uses. It does REPORT, read-only and
after the commit, when accept would refuse for SSO below Enterprise (#1420,
security LOW-4): `refusal` in the answer, so the page says so before Join
and names the workspace, which a 403 body would not carry.

It does lock `users[me]` FIRST, and that is not decoration. Writing
`claimed_by_user_id` makes Postgres take a `FOR KEY SHARE` on that user row
through the foreign key — so without this line the lock order here would be
Invitation → User while `accept_invitation` takes Team → User → Invitation.
Same user, two tabs (claim fires on page load, accept on click) and that is
a genuine cycle: `40P01`, one side 500s. SQLite has no row locks, so CI
would never have shown it. Taking the user row up front makes both paths
acquire User before Invitation.

## Request body

- TeamInvitationAccept: Raw-token request body — used by preview and claim.
  - `token` string, required

## Response `200`

Successful Response

- TeamClaimedInvitationRead: The answer to a claim: the pending shape, plus why joining is refused, if it is. ``refusal`` is the machine-readable cause the accept route would answer with (today only ``sso_plan_required``, #1420), so the page can say so before the Join click. The claimant presented the token, so they already hold every field ``/invitations/preview`` returns for it; this adds the cause and no data.
  - `workspace_name` string, required
  - `role` string, required
  - `expires_at` string, date-time, required
  - `id` string, uuid, required
  - `team_id` string, uuid, required
  - `refusal` string, nullable

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/badgeia/apis/badge.md) · [All operations](https://skmtc.dev/badgeia/apis/badge/llms.txt) · [OpenAPI document](https://skmtc.dev/badgeia/apis/badge/revisions/7e7f7fee8b77?raw)
