API reference
Quickstart
Create a key from a signed-in session, then use it. Three minutes, no SDK. The samples beside every endpoint on this page are the same three languages.
The base URL is the default Supabase host for now. A custom domain is planned, and when it lands this base URL keeps working, so nothing you build against it today breaks.
Authentication
Every endpoint except GET /v1/health needs a bearer credential. There are two, and the API tells them apart by prefix.
Personal access token
lw_pat_...The one to use. Scope limited, optionally expiring, and revocable on its own without touching your other keys.
Session JWT
Supabase access tokenFor first-party clients. Holds every scope, matching what the same token can already do against the database. Required to manage keys.
- The plaintext key is returned once, in that response. Only a hash and a short display prefix are stored, so a lost key is replaced rather than recovered.
- A key cannot create another key.
POST /v1/tokensrejects token callers, because a key that mints keys turns one leak into access that revoking the original does not stop. - Ten active keys per account. Revoke one to make room.
Scopes
A key holds exactly the scopes it was created with. Ask for the narrowest set that does the job: every endpoint below lists what it needs.
profile:write is separate from profile:read because it changes the username, and the username is part of every public collection URL. obsidian:read is legacy and used only by the Obsidian plugin's own sync endpoint.
Responses
Success and failure have one shape each. There is no third case, and no endpoint returns a bare array.
A list response carries meta; a single resource does not. Both put the payload in data.
Errors
Branch on error.code, never on the message and never on the status alone. The set is closed and the message is free to change; status 402 is ambiguous between two codes.
Error codes
invalid_request400The request was malformed, or a parameter was out of range.unauthorized401Missing, invalid, expired or revoked credential.forbidden403Authenticated, but the token lacks the scope, or API access is disabled for the account.not_found404No such resource, or it belongs to someone else. The two are deliberately indistinguishable.conflict409The write collided with something that already exists.rate_limited429Over the per-minute limit or the daily quota. Honour Retry-After.plan_limit402A plan quota is exhausted, such as saved links or chat credits.pro_required402The endpoint is Pro only.internal500A bug on our side. The x-request-id header identifies the request.Every response carries an x-request-id header. Quote it when reporting a problem.
Pagination
List endpoints take limit and cursor. The cursor is opaque: read it from meta.next_cursor and pass it back unchanged. Do not parse it, and do not construct one. When meta.has_more is false, you have everything.
Default 25, maximum 100.
Rate limits
Twenty requests per minute per key on Free, 120 on Pro and Pro + AI, in a fixed window. Every response carries the current state, so a well-behaved client never has to guess.
Headers
x-ratelimit-limitRequests allowed in the current window.x-ratelimit-remainingHow many are left.x-ratelimit-resetUnix seconds when the window rolls over.retry-afterSeconds to wait. Present only on a 429.429 means slow down and retry. A 403 saying API access is disabled means stop: waiting will not fix it, and retrying makes things worse.Links
9 endpointsThe library. Saving, reading, organising and deleting links.
/v1/linksList links
Newest first by default. All filters combine with AND.
Note the tri-state booleans: omitting archived is not the same as
archived=false. Omitted means no filter on that column.
Query parameters
cursorstringoptionalFrom a previous response's meta.next_cursor. Opaque: do not construct
or parse one. Takes precedence over limit.
limitintegeroptionaldefault 25Items per page.
collection_iduuidoptionaltag_iduuidoptionalfeed_iduuidoptionalarchivedbooleanoptionalDefaults to false, i.e. archived links are hidden.
is_visitedbooleanoptionalhas_insightsbooleanoptionalno_collectionbooleanoptionalOnly links not filed in any collection.
is_emailbooleanoptionalOnly links that arrived by email drop.
sort_bystringoptionalOne of created_at, updated_at, title
sort_orderstringoptionaldefault descOne of asc, desc
Returns
200A page of links401unauthorizedMissing, invalid, revoked or expired credential403forbiddenAuthenticated, but this token lacks the required scope429rate_limitedToo many requests
/v1/linksSave a link
Returns immediately. Fetching, parsing, chunking and embedding happen
asynchronously, so processing_status will be pending on the response
and the reader content and transcript endpoints will 404 until it
completes.
Idempotent, on a best-effort basis. Saving a URL already in your
library returns 200 with the existing link and meta.already_existed
true, rather than creating a duplicate. It is best-effort because the
canonical URL is only known after the link is fetched and redirects are
followed, so a link submitted twice in quick succession, or submitted
under two URLs that redirect to the same place, can still produce two
rows. Check the status code to tell the cases apart: 201 created,
200 already there.
Plan limits are enforced with the same check_user_limits RPC the apps
use, so this cannot be used to exceed a quota the UI honours. The limit
is only consulted when a link is actually created.
Body
urlurirequiredtitlestringoptionalOverrides the title parsed from the page.
descriptionstringoptionalYour own summary. Kept: process_link only fills description when the link has none, so supplying one here is not overwritten when the page is parsed.
collection_iduuid | nulloptionaltagsarray of stringoptionalTag names. Any that do not exist are created.
Returns
200This URL was already in the library. The existing link is returned and nothing was created.201Link saved and queued for processing400invalid_requestMalformed or missing parameters401unauthorizedMissing, invalid, revoked or expired credential402A plan quota is exhausted403forbiddenAuthenticated, but this token lacks the required scope
/v1/links/{id}Get a link
Returns
200The link404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
/v1/links/{id}Update a link
Only the fields present in the body are changed.
is_pinned is applied through the pin RPC rather than written directly,
because the number of pins allowed depends on the plan. Sending it
toggles the pin rather than setting it to the value supplied.
Body
titlestring | nulloptionaldescriptionstring | nulloptionalYour own summary. Send null to clear it.
collection_iduuid | nulloptionalis_visitedbooleanoptionalarchivedbooleanoptionalTrue sets archived_at to now; false clears it.
is_pinnedbooleanoptionalToggles the pin. Subject to the per-plan pin cap.
Returns
200Updated400invalid_requestMalformed or missing parameters404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
/v1/links/{id}Delete a link
A soft delete, matching the apps: the row is marked deleted and stops appearing, and is purged later by the archive lifecycle job.
Returns
204Deleted404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
/v1/links/{id}/contentGet reader content
The parsed, sanitised article body, plus a computed outline. 404s while the link is still processing, or when the page yielded no readable article.
Returns
200Reader content404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
/v1/links/{id}/transcriptGet a transcript
For YouTube and other media links. 404s when there is none.
Returns
200Transcript with timed segments404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
/v1/links/{id}/insightsGet AI insights for a link
Read-only. Generating an insight is metered and asynchronous; that endpoint is not in v1 yet. 404s when none has been generated.
Returns
200The insight404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Collections
5 endpoints/v1/collectionsList collections
Returns every collection with its link count. Not paged.
Returns
200All collections
/v1/collectionsCreate a collection
Body
namestringrequireddescriptionstringoptionalcolor_tagstringoptionalicon_namestringoptionalReturns
201Created402A plan quota is exhausted
/v1/collections/{id}Get a collection
Returns
200The collection404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
/v1/collections/{id}Update a collection
Body
namestringoptionaldescriptionstring | nulloptionalcolor_tagstring | nulloptionalicon_namestring | nulloptionalis_publicbooleanoptionalPublishes the collection at a shareable URL.
is_pinnedbooleanoptionalToggles the pin. Subject to the per-plan cap.
Returns
200Updated404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
/v1/collections/{id}Delete a collection
Soft delete. Links inside it are not deleted, only unfiled.
Returns
204Deleted404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Highlights
4 endpoints/v1/highlightsList highlights
Query parameters
cursorstringoptionalFrom a previous response's meta.next_cursor. Opaque: do not construct
or parse one. Takes precedence over limit.
limitintegeroptionaldefault 25Items per page.
link_iduuidoptionalOnly highlights on this link.
sort_bystringoptionaldefault created_atOne of created_at, updated_at
sort_orderstringoptionaldefault descOne of asc, desc
Returns
200A page of highlights
/v1/highlightsCreate a highlight
Offsets are character positions into the reader content, so a highlight created against one parse may not line up if the article is re-parsed.
Body
link_iduuidrequiredselected_textstringrequiredstart_offsetintegerrequiredend_offsetintegerrequiredarticle_urlurioptionalDefaults to the link's own URL.
colorstringoptionalannotationstringoptionalReturns
201Created400invalid_requestMalformed or missing parameters404not_foundThe link does not exist or is not yours
/v1/highlights/{id}Update a highlight
Only the colour and annotation can change. Offsets and text are immutable.
Body
colorstring | nulloptionalannotationstring | nulloptionalReturns
200Updated404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
/v1/highlights/{id}Delete a highlight
A hard delete. Highlights have no soft-delete column.
Returns
204Deleted404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Search
3 endpoints/v1/searchSearch the library
hybrid is the ranked path the apps use and is the default. fast is
trigram and full-text only: cheaper and lower latency, but it will not
find a document that never uses the query's words.
Semantic-only search is deliberately absent. It needs a query embedding, which costs credits, so it sits behind chat instead.
Query parameters
qstringrequiredmodestringoptionaldefault hybridOne of hybrid, fast
cursorstringoptionalFrom a previous response's meta.next_cursor. Opaque: do not construct
or parse one. Takes precedence over limit.
limitintegeroptionaldefault 25Items per page.
collection_iduuidoptionaltag_iduuidoptionalReturns
200Ranked results400invalid_requestMalformed or missing parameters
/v1/search/suggestType-ahead suggestions
Query parameters
qstringrequiredReturns
200Suggestions
/v1/search/highlightsSearch highlights
Full-text over highlighted passages and their annotations.
Query parameters
qstringrequiredlimitintegeroptionaldefault 25Items per page.
Returns
200Matching highlights
Discover
3 endpointsThe recommendation feed and the interactions that train it.
/v1/discoverGet the discovery feed
Personalised, ranked against a taste vector built from what you save and like.
The ordering is seeded. Omit seed and one is generated for you and
returned in meta.seed; the returned next_cursor carries it. Pass the
same seed to keep paging through one stable ordering. Change it to
reshuffle.
Query parameters
cursorstringoptionalFrom a previous response's meta.next_cursor. Opaque: do not construct
or parse one. Takes precedence over limit.
limitintegeroptionaldefault 25Items per page.
seedintegeroptionalStabilises ordering across pages.
categoriesstringoptionalComma-separated.
kindsstringoptionalComma-separated item kinds.
Returns
200A page of the feed
/v1/discover/{id}/interactionsRecord an interaction
Trains the recommendation model. Recording any interaction also marks the item as seen, so it stops being served again.
Body
eventstringrequiredOne of open, save, like, dislike, skip, dwell, share
dwell_msintegeroptionalMilliseconds spent on the item. Meaningful with dwell.
Returns
204Recorded400invalid_requestMalformed or missing parameters
/v1/discover/{id}/saveSave a discovery item to the library
Requires both discover:write and links:write.
Returns
201Saved402A plan quota is exhausted
Feeds
4 endpointsRSS subscriptions.
/v1/feedsList subscriptions
Query parameters
cursorstringoptionalFrom a previous response's meta.next_cursor. Opaque: do not construct
or parse one. Takes precedence over limit.
limitintegeroptionaldefault 25Items per page.
Returns
200A page of subscribed feeds
/v1/feedsSubscribe to a feed
Send url to discover a feed from a site address, handle or feed URL.
Discovery tries the platform-specific path first (Substack, Medium,
Ghost, WordPress), then <link rel=alternate>, then common paths.
Send feed_id instead to subscribe to a feed already known to Linkwise,
which skips discovery entirely.
Returns
201Subscribed400invalid_requestNo feed could be found at that address402A plan quota is exhausted
/v1/feeds/{id}Unsubscribe
Removes your subscription. The feed itself is shared and is not deleted.
Returns
204Unsubscribed404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
/v1/feeds/exportExport subscriptions as OPML
Standard OPML 2.0, importable by any other reader.
Returns
200An OPML document
AI
4 endpointsChat over a link, a collection or the whole library, plus speech.
/v1/chatChat over your library
Streams Server-Sent Events.
library scope requires Pro. link and collection scopes require
context_id.
Not yet available with a personal access token. The upstream chat functions resolve their user from a session JWT and there is nothing to forward for a PAT, so a PAT gets an explicit error rather than being silently run as the wrong user.
Body
querystringrequiredscopestringoptionalOne of library, link, collection
context_iduuidoptionalRequired when scope is link or collection.
conversation_iduuidoptionalOmit to start a new conversation.
Returns
200An SSE stream of tokens and tool events400invalid_requestMalformed or missing parameters402The feature requires a Pro subscription500Not available with a personal access token
/v1/conversationsList conversations
Query parameters
cursorstringoptionalFrom a previous response's meta.next_cursor. Opaque: do not construct
or parse one. Takes precedence over limit.
limitintegeroptionaldefault 25Items per page.
qstringoptionalSubstring filter on conversation title.
context_typestringoptionalOne of link, collection, library
Returns
200A page of conversations
/v1/conversations/{id}/messagesGet conversation messages
Query parameters
cursorstringoptionalFrom a previous response's meta.next_cursor. Opaque: do not construct
or parse one. Takes precedence over limit.
limitintegeroptionaldefault 25Items per page.
Returns
200A page of messages
/v1/ttsSynthesise speech
Requires Pro, and charges credits. Returns a URL to cached audio plus speech marks for word-level highlighting.
Not yet available with a personal access token, for the same reason
as /v1/chat.
Body
textstringrequiredlinkIduuidoptionalvoiceIdstringoptionalspeednumberoptionalincludeSpeechMarksbooleanoptionalparagraphIndexintegeroptionalReturns
200Synthesised audio402The feature requires a Pro subscription
Account
4 endpoints/v1/meGet the current account
Identity, plan and how this request authenticated. Deliberately narrow: it does not return deletion tokens, suspension state or onboarding internals.
Returns
200The account
/v1/meUpdate the profile
Username uniqueness and format are validated server-side.
Body
usernamestringoptionalavatar_urlurioptionalReturns
200Updated409conflictAlready exists
/v1/me/usageGet credit usage
Returns
200Credits assigned, used and remaining
/v1/me/limitsCheck a plan limit
Ask whether an action is currently allowed, before attempting it.
Query parameters
actionstringoptionaldefault add_linkOne of add_link, add_collection, subscribe_feed
collection_iduuidoptionalRelevant to per-collection link limits.
Returns
200Whether the action is allowed, and the relevant quota
Tokens
3 endpointsPersonal access token management. Session JWT only.
/v1/tokensList personal access tokens
Session JWT only. Never returns token values, only their display prefixes.
Returns
200Active tokens403forbiddenCalled with a personal access token
/v1/tokensCreate a personal access token
Session JWT only. A token cannot mint another token.
The plaintext value is returned once, here, and never again. Only a SHA-256 hash and a display prefix are stored.
Maximum ten active tokens per account.
Body
namestringrequiredscopesarray of stringoptionalexpires_in_daysintegeroptionalOmit for a token that never expires.
Returns
201Created. `token` appears only in this response.400invalid_requestMalformed or missing parameters
/v1/tokens/{id}Revoke a token
Session JWT only. Takes effect immediately.
Returns
204Revoked404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Meta
1 endpoints/v1/healthLiveness check
The only unauthenticated endpoint, so uptime checks need no credential.
Returns
200Service is up