# Invitations

Invitations are how an organization adds team members without sharing a login. Every resource in WarpLink sits under an organization, and every organization has a roster of members with a role: owner, admin, or member. Creating an invitation reserves a seat and emails the invitee a link; the invitation itself is not membership. Membership only exists once the invitee accepts, which happens through the emailed link on the dashboard, not through this API. Until that happens, the invitation sits in a `pending` state that this endpoint set can create, list, and revoke.

An invitation is scoped to one organization and one email address, and it carries the role the invitee will hold once they accept: `admin` or `member`. There is no `owner` option here. Ownership transfers through a separate dashboard flow, not through the invitation path, because an org can have exactly one owner at a time and inviting a second one would be a contradiction. An admin can manage links, apps, and the team roster (short of removing an owner); a member can pause and resume any link, edits and deletes only the links they created, creates and edits apps, and reads analytics. Deleting an app and managing custom domains stay with owners and admins. See the role-permission matrix below for exactly which operations each role can reach.

## Authentication and Permissions

Every invitation request authenticates with a Bearer API key and needs the scope for the operation:

| Operation | Scope | Role required (dashboard-created keys) |
|-----------|-------|------------------------------------------|
| Create an invitation | `team:write` | `owner` or `admin` (`team:manage`) |
| List invitations | `team:read` | `owner` or `admin` (`team:manage`) |
| Revoke an invitation | `team:write` | `owner` or `admin` (`team:manage`) |

The scope check and the role check are independent, and both run on every one of these three operations, including the read. A key that only has `team:read` is checked against the same `team:manage` permission a `team:write` key would need, because the permission matrix in `permissions.ts` grants `team:manage` to `owner` and `admin` only. A `member` role never has `team:manage`, so a key created by a member is refused with `403 FORBIDDEN` even for listing invitations, regardless of which scopes the key carries. This mirrors the dashboard UI, where team management is an admin-and-above surface. The role check only applies to keys that carry a `created_by`, which is how the dashboard tags keys created through its own UI. A key created without that context skips the role check and is authorized on scope alone; see [Authentication](https://warplink.app/docs/authentication) for how that distinction works end to end.

## Plan Limits

Invitations consume the organization's member seat limit, which is a plan-level number, not a per-invitation setting:

| Plan | Member limit |
|------|---------------|
| Free | 1 |
| Starter | 3 |
| Growth | 10 |
| Scale | Unlimited |
| Unlimited | Unlimited |

The Free plan's limit of 1 means the org itself is the only seat, so team invitations are refused outright with `403 SEAT_LIMIT_EXCEEDED` before the server even counts current members. Every plan above Free enforces the same limit the other way: the server counts active seats (accepted members plus any invitations still pending) and refuses a new invitation with the same `403 SEAT_LIMIT_EXCEEDED` code once the count reaches the plan's `memberLimit`. Revoking a stale invitation, or upgrading the plan, frees a seat immediately. The server also auto-revokes any invitation to that same email address that has already passed its expiry, so a re-invite doesn't collide with the old one.

## Validation and Behavior

- `email` must be a valid email address, up to 255 characters.
- `role` must be `admin` or `member`. Any other value, including `owner`, fails validation with `400 VALIDATION_ERROR`.
- Invitations expire 7 days after creation. There is no option to set a longer or shorter window.
- An email already holding active membership in the organization is refused with `409 ALREADY_MEMBER`, checked before the seat limit.
- An email with an existing, still-active invitation to the same organization is refused with `409 DUPLICATE_INVITATION`. This is enforced at the database level as a unique constraint, not just an application check, so it holds even under concurrent requests.
- Sending the invitation email is fire-and-forget: the invitation row is created and returned first, and the email dispatch happens after. A delivery failure never turns a successful `201` into an error, and never rolls back the created invitation.
- Acceptance is not part of this API. The email links to a dashboard page (`/invitations/accept`) that validates the token, checks the same expiry and state rules described below, and adds the member. There is no `POST /v1/invitations/{id}/accept` endpoint to script against.

## Create an Invitation

POST /invitations: Create an invitation

The response is the created invitation, including its `token`. This is the only response in this API that returns the token; keep it if you're building your own acceptance flow, since the dashboard's emailed link is the only other place it appears.

## List Invitations

GET /invitations: List invitations

Results default to `status=pending`. Pass `status=all` to see accepted, revoked, and expired invitations alongside pending ones, or filter to exactly one of those states. List items never include the `token` field, so listing invitations is safe to log or display without leaking the acceptance credential; only the single-invitation response from creating or revoking one carries it.

## Revoke an Invitation

DELETE /invitations/{id}: Revoke an invitation

Revoking is the only way to cancel a pending invitation early and free its seat before the 7-day window closes on its own. An invitation can only be revoked while it is still `pending`; the endpoint checks the invitation's current state before acting and refuses with a `400` naming exactly which state it's in.

## Worked Example

Invite a new admin, list pending invitations, then revoke one that's no longer needed:

```bash
curl -X POST https://api.warplink.app/v1/invitations \
  -H "Authorization: Bearer wl_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "teammate@example.com", "role": "admin" }'
```

```json
{
  "id": "8f1c2e40-...",
  "org_id": "3a9d1e20-...",
  "email": "teammate@example.com",
  "role": "admin",
  "invited_by": "1b2c3d40-...",
  "token": "9f86d081884c7d659a2feaa0c55ad015...",
  "expires_at": "2026-09-23T12:00:00.000Z",
  "accepted_at": null,
  "revoked_at": null,
  "created_at": "2026-09-16T12:00:00.000Z",
  "updated_at": "2026-09-16T12:00:00.000Z"
}
```

```bash
curl "https://api.warplink.app/v1/invitations?status=pending&limit=20" \
  -H "Authorization: Bearer wl_live_YOUR_API_KEY"
```

```json
{
  "data": [
    {
      "id": "8f1c2e40-...",
      "org_id": "3a9d1e20-...",
      "email": "teammate@example.com",
      "role": "admin",
      "invited_by": "1b2c3d40-...",
      "expires_at": "2026-09-23T12:00:00.000Z",
      "accepted_at": null,
      "revoked_at": null,
      "created_at": "2026-09-16T12:00:00.000Z",
      "updated_at": "2026-09-16T12:00:00.000Z"
    }
  ],
  "pagination": { "total": 1, "limit": 20, "offset": 0, "has_more": false }
}
```

```bash
curl -X DELETE https://api.warplink.app/v1/invitations/8f1c2e40-... \
  -H "Authorization: Bearer wl_live_YOUR_API_KEY"
```

The revoke response echoes the invitation with `revoked_at` set to the current time and `role` unchanged, so a caller scripting cleanup can confirm which invitation it just canceled without a follow-up read.

## Common Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | `email` isn't a valid address, or `role` isn't `admin` or `member` |
| 400 | `INVALID_ID` | The `id` path parameter isn't a valid UUID |
| 400 | `INVITATION_ALREADY_ACCEPTED` | Revoking an invitation the invitee already accepted |
| 400 | `INVITATION_ALREADY_REVOKED` | Revoking an invitation that was already revoked |
| 400 | `INVITATION_EXPIRED` | Revoking an invitation past its 7-day window |
| 403 | `FORBIDDEN` | The key lacks the scope, or its creator's role lacks `team:manage` |
| 403 | `SEAT_LIMIT_EXCEEDED` | The plan's member limit is reached, or the plan doesn't support invitations at all |
| 404 | `NOT_FOUND` | No invitation with that ID exists in this organization |
| 409 | `ALREADY_MEMBER` | That email already belongs to a member of the organization |
| 409 | `DUPLICATE_INVITATION` | That email already has an active, unrevoked invitation |

## FAQ

**Can I invite someone as an owner?**
No. `role` only accepts `admin` or `member`. Ownership moves through a separate transfer flow with exactly one owner at a time, never through an invitation.

**What happens to the seat if nobody accepts within 7 days?**
The invitation stops counting toward the seat limit as soon as it expires; revoking it (explicitly, or automatically the next time someone invites that same email) just clears the stale row so a re-invite doesn't collide with it. An expired invitation that nobody revokes still occupies a "pending" row, but a fresh invite attempt to the same address triggers the auto-revoke and clears it.

**Does revoking an invitation remove an existing team member?**
No. Revoking only cancels a `pending` invitation before it's accepted. Removing an existing member is a separate team-management operation, not exposed on this endpoint.

**Why did my request fail with `SEAT_LIMIT_EXCEEDED` even though I have open seats?**
On the Free plan, the member limit is 1, which is the org's own seat. That plan blocks invitations outright rather than counting toward a limit greater than one, so the fix is upgrading rather than freeing a seat.

**Can a member role create or list invitations?**
No, even with a key scoped `team:read` or `team:write`. Managing the team, including reading the pending list, requires the `team:manage` permission, which only `owner` and `admin` roles hold.
