Invitations
Invitations API endpoints to invite team members by email, list pending invitations, and revoke access, with role assignment, seat limits, and a 7-day expiry.
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 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
emailmust be a valid email address, up to 255 characters.rolemust beadminormember. Any other value, includingowner, fails validation with400 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
201into 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 noPOST /v1/invitations/{id}/acceptendpoint to script against.
Create an Invitation
/invitationsCreate an invitation
Request Body
| Field | Type | Description |
|---|---|---|
email* | string | |
role* | "admin" | "member" |
Responses
201Invitation created
| Field | Type | Description |
|---|---|---|
id | string | |
org_id | string | |
email | string | |
role | "admin" | "member" | |
invited_by | string | null | |
token | string | |
expires_at | string | |
accepted_at | string | null | |
revoked_at | string | null | |
created_at | string | |
updated_at | string |
400Validation error
| Field | Type | Description |
|---|---|---|
error* | object |
401Unauthorized
| Field | Type | Description |
|---|---|---|
error* | object |
403Forbidden or seat limit exceeded
| Field | Type | Description |
|---|---|---|
error* | object |
409Already a member or duplicate invitation
| Field | Type | Description |
|---|---|---|
error* | object |
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
/invitationsList invitations
Parameters
| Name | Type | In | Description |
|---|---|---|---|
limit | integer | query | |
offset | integer | query | |
status | "pending" | "accepted" | "revoked" | "expired" | "all" | query |
Responses
200List of invitations
| Field | Type | Description |
|---|---|---|
data | object[] | |
pagination | object |
401Unauthorized
| Field | Type | Description |
|---|---|---|
error* | object |
403Forbidden
| Field | Type | Description |
|---|---|---|
error* | object |
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
/invitations/{id}Revoke an invitation
Parameters
| Name | Type | In | Description |
|---|---|---|---|
id* | string | path |
Responses
200Revoked invitation
| Field | Type | Description |
|---|---|---|
id | string | |
org_id | string | |
email | string | |
role | "admin" | "member" | |
invited_by | string | null | |
token | string | |
expires_at | string | |
accepted_at | string | null | |
revoked_at | string | null | |
created_at | string | |
updated_at | string |
400Invalid ID, already accepted, already revoked, or expired
| Field | Type | Description |
|---|---|---|
error* | object |
401Unauthorized
| Field | Type | Description |
|---|---|---|
error* | object |
403Forbidden
| Field | Type | Description |
|---|---|---|
error* | object |
404Not found
| Field | Type | Description |
|---|---|---|
error* | object |
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:
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" }'
{
"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"
}
curl "https://api.warplink.app/v1/invitations?status=pending&limit=20" \
-H "Authorization: Bearer wl_live_YOUR_API_KEY"
{
"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 }
}
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.
Exports
Exports endpoints to start a click export for an organization or one link, poll its status, and download the identical CSV file as often as you need.
Attribution
Attribution API endpoint to match an app install to the link click that drove it, using the referrer, device ID, probabilistic match, and raw-signals cascade.