Documents and lists can be shared three ways:
Both resources use one identical sharing model, so the roles, gating, and semantics below apply equally to documents and lists.
Every grant, whether by person or by link, carries one of three roles:
| Role | UI label | Can do |
|---|---|---|
watcher | Viewer | Read-only view of the resource. |
collaborator | Editor | View and edit content (list rows / document body). |
manager | Admin | Everything Editor can, plus edit the resource's settings/schema and delete it. |
The true owner always outranks every grant and is the only party who can create, list, or revoke share links and manage per-person access; even a manager cannot.
| Action | Auth | Gating |
|---|---|---|
| Create a share link | Session or Bearer | Owner only, and subscriber only: a free owner gets 403. |
| List a resource's share links | Session or Bearer | Owner only (404 otherwise). |
| Revoke a share link | Session or Bearer | Owner only (404 otherwise). Not subscriber-gated: a downgraded owner can always revoke access they previously granted. |
| Open (resolve) a share link | Optional (anonymous allowed) | Anyone with the token. Viewer links grant anonymous read; Editor/Admin links return read-only data plus a prompt to sign in and claim. |
| Claim a share link | Session only | Any signed-in user. Upserts a real role grant for Editor/Admin links. |
Recipients never need a subscription to view, edit, or claim; only the granting owner must subscribe to create a link.
A share link's token is a 256-bit random capability (base64url-encoded). Possession of the token is the grant, so treat it like a password. Anyone who has the token has whatever access the link's role confers, so:
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/documents/:id/share-links | Session or Bearer | List a document's active share links. Owner only. |
| POST | /api/documents/:id/share-links | Session or Bearer | Create a document share link. Owner only. Subscriber only. |
| DELETE | /api/documents/:id/share-links/:token | Session or Bearer | Revoke a document share link. Owner only. |
| GET | /api/documents/shared/:token | Optional | Resolve a document share link for viewing. |
| POST | /api/documents/shared/:token | Session or Bearer | Claim a document Editor/Admin link as the signed-in user. |
| GET | /api/lists/:id/share-links | Session or Bearer | List a list's active share links. Owner only. |
| POST | /api/lists/:id/share-links | Session or Bearer | Create a list share link. Owner only. Subscriber only. |
| DELETE | /api/lists/:id/share-links/:token | Session or Bearer | Revoke a list share link. Owner only. |
| GET | /api/lists/shared/:token | Optional | Resolve a list share link for viewing. |
| GET | /api/lists/shared/:token/data | Public (optional auth) | Read-only row data for a shared list. |
| POST | /api/lists/shared/:token | Session or Bearer | Claim a list Editor/Admin link as the signed-in user. |
The document and list forms are identical apart from the resource path and the response's resource object (document vs list, documentId vs listId). The examples below use lists; swap lists/listId/list for documents/documentId/document for the document equivalents.
POST /api/lists/lst_abc001/share-links
Content-Type: application/json
{ "role": "watcher", "expiresAt": null }
| Field | Type | Description |
|---|---|---|
role | string | Optional. One of watcher, collaborator, manager. Defaults to watcher. An invalid value returns 400. |
expiresAt | string | null | Optional ISO 8601 datetime after which the link stops resolving. Omit or null for a link that never expires. An unparseable value returns 400. |
Response (201):
{
"token": "xN3v…base64url…9Qk",
"url": "https://interlinedlist.com/lists/shared/xN3v…9Qk",
"role": "watcher",
"expiresAt": null
}
url is the ready-to-share landing address. A free (non-subscriber) owner receives 403 { "error": "Subscribe to share lists." } (or "Subscribe to share documents."). A caller who does not own the resource receives 404 (existence is never leaked).
GET /api/lists/lst_abc001/share-links
{
"shareLinks": [
{
"token": "xN3v…9Qk",
"role": "watcher",
"expiresAt": null,
"createdAt": "2025-06-11T09:00:00.000Z",
"revokedAt": null,
"url": "https://interlinedlist.com/lists/shared/xN3v…9Qk"
}
]
}
Only active (non-revoked) links are returned, newest first. Owner only: a non-owner gets 404.
DELETE /api/lists/lst_abc001/share-links/xN3v…9Qk
Returns { "revoked": true } on success, or 404 if the link does not exist under that resource or you are not the owner. Revocation takes effect immediately: the token stops resolving on its next use. This endpoint is intentionally not subscriber-gated so an owner whose subscription lapsed can still shut off previously created links.
GET /api/{lists|documents}/shared/:token is the only cross-user / anonymous read path. Authentication is optional:
GET /api/lists/shared/xN3v…9Qk
{
"role": "watcher",
"canClaim": false,
"needsAuth": false,
"list": {
"id": "lst_abc001",
"title": "Books to Read",
"description": "My reading backlog.",
"isPublic": false,
"updatedAt": "2025-06-11T09:00:00.000Z"
}
}
watcher) links resolve for anyone, signed in or not, and grant read-only access to the resource object.collaborator / manager) links also return the resource read-only, plus:
canClaim: true when the viewer is signed in: prompt them to POST and claim the edit grant.needsAuth: true when the viewer is anonymous: prompt them to sign in first.There are no anonymous writes: an Editor/Admin link must be claimed by a signed-in user before that user can edit, so every change stays attributable to a real account.
A 404 ("Share link not found, expired, or revoked") is returned for any unknown, revoked, or expired token, or when the underlying resource has been deleted; the four cases are deliberately indistinguishable. This endpoint is rate-limited per IP to blunt token-guessing.
A shared list also exposes its row data through a dedicated read-only endpoint. This is what lets a Viewer link render a private list's rows on the share landing page without any account:
GET /api/lists/shared/xN3v…9Qk/data?limit=100&offset=0
isPublic flag and with no session.GET /api/lists/:id/data endpoint (limit, offset, page, sort, order, plus per-column filters) and returns the same row payload shape.404 is returned for any unknown/revoked/expired token or a deleted list.POST /api/lists/shared/xN3v…9Qk
Cookie: session=<session id list>
Claiming is authenticated by the session cookie only (not a Bearer sync token): a browser visitor clicks "Start editing" and the request carries their session. For an Editor or Admin link, this upserts a real access grant (a list watcher / document collaborator row) at the link's role for the signed-in user; they then edit through the normal authenticated endpoints. Requires authentication (401 if not signed in). Returns the resolved resource id and role:
{ "listId": "lst_abc001", "role": "collaborator" }
(For documents the key is documentId.) Claiming a Viewer link is a harmless no-op; plain viewing needs no grant. A 404 is returned if the link no longer resolves.
Granting access to a named user (rather than by link) uses the collaborators/watchers endpoints, which follow the same three roles and the same subscriber-gating on the granting owner:
POST/GET /api/documents/:id/collaborators, PUT/DELETE /api/documents/:id/collaborators/:userId (see Documents).POST/GET /api/lists/:id/watchers, PUT/DELETE /api/lists/:id/watchers/:userId (see Lists).Adding or changing a person's role notifies that user (in-app, and by email unless the request sets notify: false).
An email invite grants a role to a specific email address (including a person who does not have an account yet) while the resource stays private. It differs from a share link in one crucial way:
A share link is a bearer capability: whoever holds the token has access. An invite is bound to the invited email: the token only becomes access once a signed-in user whose verified email matches the invited address claims it. A forwarded invite link is useless to anyone else.
The document and list forms are identical apart from the resource path and the claim response's id key (documentId vs listId). The examples below use documents; swap documents/documentId for lists/listId for the list equivalents.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/documents/:id/invites | Session or Bearer | Create (or re-issue) an email invite. Owner only. Subscriber only. |
| GET | /api/documents/:id/invites | Session or Bearer | List a document's invites. Owner only. |
| DELETE | /api/documents/:id/invites/:token | Session or Bearer | Revoke an invite. Owner only. Not subscriber-gated. |
| GET | /api/documents/invite/:token | Optional | Resolve an invite for its landing page. |
| POST | /api/documents/invite/:token | Session or Bearer | Claim an invite as the signed-in user. |
| POST | /api/lists/:id/invites | Session or Bearer | Create (or re-issue) a list invite. Owner only. Subscriber only. |
| GET | /api/lists/:id/invites | Session or Bearer | List a list's invites. Owner only. |
| DELETE | /api/lists/:id/invites/:token | Session or Bearer | Revoke a list invite. Owner only. |
| GET | /api/lists/invite/:token | Optional | Resolve a list invite. |
| POST | /api/lists/invite/:token | Session or Bearer | Claim a list invite. |
| Action | Auth | Gating |
|---|---|---|
| Create an invite | Session or Bearer | Owner only, and subscriber only: a free owner gets 403. The subscription gate runs before any resource lookup, so existence never leaks to free users. |
| List invites | Session or Bearer | Owner only (404 otherwise). |
| Revoke an invite | Session or Bearer | Owner only (404 otherwise). Not subscriber-gated: a downgraded owner can always revoke. |
| Resolve an invite | Optional | Anyone; the invited email is never returned to anonymous or mismatched callers. |
| Claim an invite | Session only | Requires a verified email that matches the invited address. Accepting is free. |
POST /api/documents/doc_abc001/invites
Content-Type: application/json
{ "email": "friend@example.com", "role": "collaborator", "expiresAt": null }
| Field | Type | Description |
|---|---|---|
email | string | Required. The address to invite. Validated syntactically (400 if invalid) and stored lowercased/trimmed. |
role | string | Optional. One of watcher, collaborator, manager. Defaults to watcher. An invalid value returns 400. |
expiresAt | string | null | Optional ISO 8601 datetime after which the invite stops resolving. Omit or null for no expiry. An unparseable value returns 400. |
Response (201):
{
"email": "friend@example.com",
"role": "collaborator",
"expiresAt": null,
"url": "https://interlinedlist.com/documents/invite/xN3v…9Qk"
}
url is the invite landing address; an invite email carrying it is also sent to the address (best-effort, fire-and-forget). Re-inviting the same address is idempotent: it re-issues a fresh token and resets the invite to unclaimed. A free (non-subscriber) owner receives 403 ("Subscribe to invite people to documents." / "…lists."); a non-owner receives 404 (existence is never leaked). Invite creation is per-user rate-limited (429 with Retry-After when exceeded).
GET /api/documents/doc_abc001/invites
{
"invites": [
{
"email": "friend@example.com",
"role": "collaborator",
"expiresAt": null,
"accepted": false,
"createdAt": "2025-06-11T09:00:00.000Z",
"token": "xN3v…9Qk"
}
]
}
Owner only: a non-owner gets 404. accepted flips to true once the invite has been claimed. The token is included so the owner can revoke a specific invite.
DELETE /api/documents/doc_abc001/invites/xN3v…9Qk
Returns { "revoked": true } on success, or 404 if no such invite exists under that resource or you are not the owner. Not subscriber-gated: an owner whose subscription lapsed can always revoke.
GET /api/{documents|lists}/invite/:token renders the invite landing page. Authentication is optional, and the response reveals only what's needed to show the correct branch, never the invited email:
{
"role": "collaborator",
"needsAuth": false,
"canClaim": true,
"wrongAccount": false,
"accepted": false,
"resourceTitle": "Q3 Planning"
}
| Field | Meaning |
|---|---|
role | The role the invite grants. |
needsAuth | true when no user is signed in → prompt to sign in / create an account. |
canClaim | true when the signed-in user's verified email matches the invited address → they may POST to claim. |
wrongAccount | true when a user is signed in but their email doesn't match → they must switch accounts. |
accepted | true if the invite has already been claimed (it still resolves, so the page can link the claimer into the resource). |
resourceTitle | The document/list title, for display. |
A 404 ("Invite not found, expired, or revoked") is returned for any unknown, revoked, or expired token, or when the underlying resource has been deleted; these cases are deliberately indistinguishable. Rate-limited per IP to blunt token-guessing.
POST /api/documents/invite/xN3v…9Qk
Cookie: session=<session id list>
Claiming is authenticated by the session cookie only (not a Bearer sync token). On success it upserts a real role grant (a document collaborator / list watcher row) at the invite's role (for every role, including watcher, because a personal invite to a private resource is the access) and marks the invite accepted (idempotent).
{ "documentId": "doc_abc001", "role": "collaborator" }
(For lists the key is listId.) Error cases:
| Status | Condition |
|---|---|
401 | Not signed in. |
403 | Email not verified yet ("Verify your email to accept this invite."). |
403 | Signed-in email doesn't match the invited address ("This invite was sent to a different email address."). |
404 | Invite no longer resolves (unknown / revoked / expired / resource deleted). |
Because a brand-new signup starts unverified, an invited person who just created an account must verify their email first, then claim. To land them back on the invite after signup, the register flow honors a sanitized same-origin returnUrl.