Sharing & Share Links

Documents and lists can be shared three ways:

  1. By person: grant a named, existing user access at a role, via the collaborators/watchers endpoints (see Documents and Lists).
  2. By link: mint a secret, tokenized share link that grants a role to anyone who opens it. This page covers share links.
  3. By email invite: invite a specific email address (a person who may not have an account yet) to a private resource. Unlike a share link, an invite is bound to that email: it only becomes access once a signed-in user with that verified email claims it. See Email invites below.

Both resources use one identical sharing model, so the roles, gating, and semantics below apply equally to documents and lists.

Roles

Every grant, whether by person or by link, carries one of three roles:

RoleUI labelCan do
watcherViewerRead-only view of the resource.
collaboratorEditorView and edit content (list rows / document body).
managerAdminEverything 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.

Who can do what

ActionAuthGating
Create a share linkSession or BearerOwner only, and subscriber only: a free owner gets 403.
List a resource's share linksSession or BearerOwner only (404 otherwise).
Revoke a share linkSession or BearerOwner only (404 otherwise). Not subscriber-gated: a downgraded owner can always revoke access they previously granted.
Open (resolve) a share linkOptional (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 linkSession onlyAny 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.

The token is a secret

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:

  • Only share the link over trusted channels; never post it publicly.
  • To cut off access, revoke the link (below). Revocation is immediate.
  • Optionally set an expiry when creating the link so it stops working automatically.

Endpoint table

MethodPathAuthDescription
GET/api/documents/:id/share-linksSession or BearerList a document's active share links. Owner only.
POST/api/documents/:id/share-linksSession or BearerCreate a document share link. Owner only. Subscriber only.
DELETE/api/documents/:id/share-links/:tokenSession or BearerRevoke a document share link. Owner only.
GET/api/documents/shared/:tokenOptionalResolve a document share link for viewing.
POST/api/documents/shared/:tokenSession onlyClaim a document Editor/Admin link as the signed-in user.
GET/api/lists/:id/share-linksSession or BearerList a list's active share links. Owner only.
POST/api/lists/:id/share-linksSession or BearerCreate a list share link. Owner only. Subscriber only.
DELETE/api/lists/:id/share-links/:tokenSession or BearerRevoke a list share link. Owner only.
GET/api/lists/shared/:tokenOptionalResolve a list share link for viewing.
GET/api/lists/shared/:token/dataOptional (token)Read-only row data for a shared list.
POST/api/lists/shared/:tokenSession onlyClaim 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 }
FieldTypeDescription
rolestringOptional. One of watcher, collaborator, manager. Defaults to watcher. An invalid value returns 400.
expiresAtstring | nullOptional 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"
  }
}
  • Viewer (watcher) links resolve for anyone, signed in or not, and grant read-only access to the resource object.
  • Editor / Admin (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.

Reading a shared list's rows

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
  • The token is the capability: rows are served regardless of the list's isPublic flag and with no session.
  • Read-only. This handler never mutates; there is no document equivalent (a document's body is returned directly by the resolve call above).
  • The token is re-checked on every request, so a revoked or expired link stops serving rows immediately.
  • Supports the same pagination/filter/sort query params as the authenticated GET /api/lists/:id/data endpoint (limit, offset, page, sort, order, plus per-column filters) and returns the same row payload shape.
  • Per-IP rate-limited, matching the resolve route. A 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.

Per-person sharing

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:

  • Documents: POST/GET /api/documents/:id/collaborators, PUT/DELETE /api/documents/:id/collaborators/:userId (see Documents).
  • Lists: 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).

Email invites

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.

Endpoint table

MethodPathAuthDescription
POST/api/documents/:id/invitesSession or BearerCreate (or re-issue) an email invite. Owner only. Subscriber only.
GET/api/documents/:id/invitesSession or BearerList a document's invites. Owner only.
DELETE/api/documents/:id/invites/:tokenSession or BearerRevoke an invite. Owner only. Not subscriber-gated.
GET/api/documents/invite/:tokenOptionalResolve an invite for its landing page.
POST/api/documents/invite/:tokenSession onlyClaim an invite as the signed-in user.
POST/api/lists/:id/invitesSession or BearerCreate (or re-issue) a list invite. Owner only. Subscriber only.
GET/api/lists/:id/invitesSession or BearerList a list's invites. Owner only.
DELETE/api/lists/:id/invites/:tokenSession or BearerRevoke a list invite. Owner only.
GET/api/lists/invite/:tokenOptionalResolve a list invite.
POST/api/lists/invite/:tokenSession onlyClaim a list invite.

Who can do what

ActionAuthGating
Create an inviteSession or BearerOwner 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 invitesSession or BearerOwner only (404 otherwise).
Revoke an inviteSession or BearerOwner only (404 otherwise). Not subscriber-gated: a downgraded owner can always revoke.
Resolve an inviteOptionalAnyone; the invited email is never returned to anonymous or mismatched callers.
Claim an inviteSession onlyRequires a verified email that matches the invited address. Accepting is free.

Creating an invite

POST /api/documents/doc_abc001/invites
Content-Type: application/json

{ "email": "friend@example.com", "role": "collaborator", "expiresAt": null }
FieldTypeDescription
emailstringRequired. The address to invite. Validated syntactically (400 if invalid) and stored lowercased/trimmed.
rolestringOptional. One of watcher, collaborator, manager. Defaults to watcher. An invalid value returns 400.
expiresAtstring | nullOptional 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).

Listing invites

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.

Revoking an 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.

Resolving an invite

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"
}
FieldMeaning
roleThe role the invite grants.
needsAuthtrue when no user is signed in → prompt to sign in / create an account.
canClaimtrue when the signed-in user's verified email matches the invited address → they may POST to claim.
wrongAccounttrue when a user is signed in but their email doesn't match → they must switch accounts.
acceptedtrue if the invite has already been claimed (it still resolves, so the page can link the claimer into the resource).
resourceTitleThe 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.

Claiming an invite

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:

StatusCondition
401Not signed in.
403Email not verified yet ("Verify your email to accept this invite.").
403Signed-in email doesn't match the invited address ("This invite was sent to a different email address.").
404Invite 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.