> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sublay.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Workspace Invitation

> The shape of a WorkspaceInvitation object, plus the identity-matched, no-token acceptance model

A **WorkspaceInvitation** is a persistent record inviting a person into a [workspace](/v8/data-models/workspace) with a pre-chosen grant (capabilities, permissions, rank, title) applied atomically on accept. Every invite **always sends an email** (there is no developer toggle).

Acceptance is **identity-matched + verified-email, with no token** — see [Acceptance model](#acceptance-model).

## Properties

| Property | Type | Description |
| - | - | - |
| `id` | `string` | Unique invitation identifier (UUID). **Non-secret** — knowing it does not let a non-target accept. |
| `workspaceId` | `string` | The target workspace. |
| `invitedBy` | `string` | User id of the inviter. |
| `userId` | `string \| null` | The resolved invitee's user id. Set at invite time when the email/username matches an existing user, and bound at signup for email-only invites. `null` only while no account exists for the address. |
| `email` | `string \| null` | The invitee email, stored **normalized** (lowercase + trim). `null` for `userId`/`username`-addressed invites. |
| `capabilities` | `string[]` | The [capabilities](/v8/data-models/workspace-member#capabilities-the-closed-vocabulary) to apply on accept. |
| `permissions` | `string[]` | The opaque [permissions](/v8/data-models/workspace-member#permissions-opaque) to apply on accept. |
| `rank` | `number` | The initial [rank](/v8/data-models/workspace-member#rank-semantics) to apply on accept — always **absolute**, whichever coordinate the inviter used. A [`relativeRank`](/v8/data-models/workspace-member#two-coordinates-rank-and-relativerank) offset (or the default, one rung below the inviter) is resolved to a number when the invitation is created and **frozen** here; it does not follow the inviter's rank afterwards. |
| `title` | `string \| null` | Optional initial cosmetic title. |
| `status` | `"pending" \| "accepted" \| "declined" \| "revoked"` | Invitation status. **There is no `expired` status** — expiry is lazy (see below). |
| `expiresAt` | `string` | Absolute expiry timestamp (ISO). Set to now + **14 days**. |
| `createdAt` | `string` | Creation timestamp. |
| `updatedAt` | `string` | Last-update timestamp. |

<Note>
  **[`GET /me/workspace-invites`](/v8/api-reference/workspaces/invitations/list-my-invites) returns an enriched variant of this shape.** Every other read of a `WorkspaceInvitation` (listing what a workspace has issued, creating one, resending one) is by a capability-holder who already has full reach on the workspace, so `workspaceId`/`invitedBy` as bare ids are enough. The my-invites read is different: it's addressed to the *invitee*, who otherwise has no reach on the workspace at all (a direct workspace read 404s for a non-member) or the inviter. So that one endpoint replaces `invitedBy` with a populated object and adds a sibling `workspace` field, so the invite can be rendered ("X invited you to *Y*") without a follow-up read per invitation:

  | Property | Type | Description |
  | - | - | - |
  | `workspace` | `{ id: string, name: string }` | The target workspace, embedded. `workspaceId` is still present alongside it. |
  | `invitedBy` | `{ id: string, name: string \| null, username: string \| null, avatar: string \| null, reputation: number }` | The inviter, embedded in place of the bare id. |
</Note>

## Lazy expiry

Expiry is **lazy** — there is no background sweep and no `expired` status or webhook. A `pending` invite past `expiresAt` is **effectively expired**: it won't accept, and "live" reads filter on `status = 'pending' AND expiresAt > now`. A capability-holder can **resend** to refresh a lapsed invite (resets a fresh 14-day expiry).

## Acceptance model

Acceptance is an authenticated, in-app action gated by **identity**, not a token:

* **No secret token** is minted or emailed. The email link is a non-secret pointer to your app (a per-project accept URL) with a `?workspaceInviteId=<id>` appended for deep-linking.
* On accept **and on decline**, Sublay verifies the caller **is** the invite's target (by `userId` or normalized `email`) **and has a verified email**.
* **A verified email is required to accept *any* invite** — userId- or email-addressed. An unverified accept returns the distinct `workspace/email-not-verified` code so your app can prompt verification specifically. A squatter who signed up with someone else's address can never accept (they can't verify it) — and **decline is gated the same way**, so they cannot burn the real target's invite by moving it to the terminal `declined` state either. (Squatters can still *see* the invite: surfacing is deliberately not verification-gated.)

Combined with the verified-email gate on root-workspace creation — which **service and master keys do not bypass** either — **every workspace participant (member or owner) has a confirmed email.**

<Note>
  **Delivery requires one project setting.** Invitation emails deep-link to your app via [`workspaces.inviteAcceptUrl`](/v8/sdk/workspaces/overview#project-settings), which has **no default**. Until it is set, creating or resending an invitation is refused with `409 workspace/missing-invite-accept-url` — including for invitees who already have an account. The link itself carries nothing secret; acceptance is identity-matched.
</Note>

### Binding to `userId`

Invitations **bind to a `userId`**: at invite time if the user already exists, and via a **signup-attach step** for new users (on signup, pending invites matching the new user's normalized email get their `userId`). So surfacing and matching are always by `userId`, and a deleted user never leaves an orphaned email-invite.

### v1 external-auth limitation

External / developer-managed-auth users have **no verified-email signal** in v1. Consequently, on a purely external-auth project:

* They **cannot act on any invite** — email- *or* `userId`-addressed — because both acceptance and decline are hard-gated on a verified email, which they lack. Inviting by `userId` lets the invite bind to the user and surface in their pending list, but they cannot accept or decline it in v1 until a verified-email signal exists.
* They **cannot create a root workspace at all** (root creation is hard-gated on a verified email, with no `userId` workaround), so **root-creation is effectively unavailable on a purely external-auth project in v1.**

This is a documented v1 limitation. A future dev-flippable project flag (treat the project's asserted emails as verified) would lift both restrictions.

## Related

* [Workspace data model](/v8/data-models/workspace)
* [Create Invite API](/v8/api-reference/workspaces/invitations/create-invite)
* [Accept Invite API](/v8/api-reference/workspaces/invitations/accept-invite)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.