Linkwise - Save, annotate, chat with everything you've read. | Product Hunt
v1.0.0 · 43 endpoints

API reference

Save, search, organise and read the things you collect. One base URL, one credential, one response shape. Everything on this page is generated from the OpenAPI document the API is built against, so it cannot describe an endpoint that does not exist.

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.

Quickstart
export LINKWISE_API="https://jcbgrqawrvztwsvxawda.supabase.co/functions/v1/api"
export LINKWISE_TOKEN="lw_pat_..."

curl -s "$LINKWISE_API/v1/links?limit=5" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"

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 token

For 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/tokens rejects 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.
Create a key
curl -s -X POST "$LINKWISE_API/v1/tokens" \
  -H "Authorization: Bearer $LINKWISE_SESSION_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CLI on laptop",
    "scopes": ["links:read", "links:write", "search:read"],
    "expires_in_days": 90
  }'

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.

links:readlinks:writecollections:readcollections:writetags:readtags:writehighlights:readhighlights:writesearch:readdiscover:readdiscover:writefeeds:readfeeds:writeai:chattts:synthesizeprofile:readprofile:writeobsidian:read

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.

Envelope
{
  "data": { "id": "...", "url": "..." },
  "meta": { "next_cursor": "eyJ...", "has_more": true }
}

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.

Paginate
CURSOR=$(curl -s "$LINKWISE_API/v1/links?limit=50" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" | jq -r .meta.next_cursor)

curl -s "$LINKWISE_API/v1/links?limit=50&cursor=$CURSOR" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"

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.
A 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.

Collections

5 endpoints
GET/v1/collections

List collections

collections:read

Returns every collection with its link count. Not paged.

Returns

  • 200All collections
Request
curl -s "$LINKWISE_API/v1/collections" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
POST/v1/collections

Create a collection

collections:write

Body

namestringrequired
descriptionstringoptional
color_tagstringoptional
icon_namestringoptional

Returns

  • 201Created
  • 402A plan quota is exhausted
Request
curl -s -X POST "$LINKWISE_API/v1/collections" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "<name>" }'
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
GET/v1/collections/{id}

Get a collection

collections:read

Returns

  • 200The collection
  • 404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Request
curl -s "$LINKWISE_API/v1/collections/<id>" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
PATCH/v1/collections/{id}

Update a collection

collections:write

Body

namestringoptional
descriptionstring | nulloptional
color_tagstring | nulloptional
icon_namestring | nulloptional
is_publicbooleanoptional

Publishes the collection at a shareable URL.

is_pinnedbooleanoptional

Toggles the pin. Subject to the per-plan cap.

Returns

  • 200Updated
  • 404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Request
curl -s -X PATCH "$LINKWISE_API/v1/collections/<id>" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "<name>", "description": "<description>" }'
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
DELETE/v1/collections/{id}

Delete a collection

collections:write

Soft delete. Links inside it are not deleted, only unfiled.

Returns

  • 204Deleted
  • 404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Request
curl -s -X DELETE "$LINKWISE_API/v1/collections/<id>" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
204 No Content

No body. The resource is gone.

Tags

3 endpoints
GET/v1/tags

List tags

tags:read

Query parameters

cursorstringoptional

From a previous response's meta.next_cursor. Opaque: do not construct or parse one. Takes precedence over limit.

limitintegeroptionaldefault 25

Items per page.

qstringoptional

Substring filter on tag name.

Returns

  • 200A page of tags with link counts
Request
curl -s "$LINKWISE_API/v1/tags" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
POST/v1/tags

Create a tag

tags:write

Idempotent. Creating a tag that already exists returns the existing one.

Body

namestringrequired

Returns

  • 201Created, or the existing tag
Request
curl -s -X POST "$LINKWISE_API/v1/tags" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "<name>" }'
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
DELETE/v1/tags/{id}

Delete a tag

tags:write

Soft delete. The tag is removed from every link that had it.

Returns

  • 204Deleted
  • 404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Request
curl -s -X DELETE "$LINKWISE_API/v1/tags/<id>" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
204 No Content

No body. The resource is gone.

Highlights

4 endpoints
GET/v1/highlights

List highlights

highlights:read

Query parameters

cursorstringoptional

From a previous response's meta.next_cursor. Opaque: do not construct or parse one. Takes precedence over limit.

limitintegeroptionaldefault 25

Items per page.

link_iduuidoptional

Only highlights on this link.

sort_bystringoptionaldefault created_at

One of created_at, updated_at

sort_orderstringoptionaldefault desc

One of asc, desc

Returns

  • 200A page of highlights
Request
curl -s "$LINKWISE_API/v1/highlights" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
POST/v1/highlights

Create a highlight

highlights:write

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_iduuidrequired
selected_textstringrequired
start_offsetintegerrequired
end_offsetintegerrequired
article_urlurioptional

Defaults to the link's own URL.

colorstringoptional
annotationstringoptional

Returns

  • 201Created
  • 400invalid_requestMalformed or missing parameters
  • 404not_foundThe link does not exist or is not yours
Request
curl -s -X POST "$LINKWISE_API/v1/highlights" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "link_id": "<link_id>", "selected_text": "<selected_text>", "start_offset": 0, "end_offset": 0 }'
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
PATCH/v1/highlights/{id}

Update a highlight

highlights:write

Only the colour and annotation can change. Offsets and text are immutable.

Body

colorstring | nulloptional
annotationstring | nulloptional

Returns

  • 200Updated
  • 404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Request
curl -s -X PATCH "$LINKWISE_API/v1/highlights/<id>" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "color": "<color>", "annotation": "<annotation>" }'
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
DELETE/v1/highlights/{id}

Delete a highlight

highlights:write

A hard delete. Highlights have no soft-delete column.

Returns

  • 204Deleted
  • 404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Request
curl -s -X DELETE "$LINKWISE_API/v1/highlights/<id>" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
204 No Content

No body. The resource is gone.

Discover

3 endpoints

The recommendation feed and the interactions that train it.

GET/v1/discover

Get the discovery feed

discover:read

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

cursorstringoptional

From a previous response's meta.next_cursor. Opaque: do not construct or parse one. Takes precedence over limit.

limitintegeroptionaldefault 25

Items per page.

seedintegeroptional

Stabilises ordering across pages.

categoriesstringoptional

Comma-separated.

kindsstringoptional

Comma-separated item kinds.

Returns

  • 200A page of the feed
Request
curl -s "$LINKWISE_API/v1/discover" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
POST/v1/discover/{id}/interactions

Record an interaction

discover:write

Trains the recommendation model. Recording any interaction also marks the item as seen, so it stops being served again.

Body

eventstringrequired

One of open, save, like, dislike, skip, dwell, share

dwell_msintegeroptional

Milliseconds spent on the item. Meaningful with dwell.

Returns

  • 204Recorded
  • 400invalid_requestMalformed or missing parameters
Request
curl -s -X POST "$LINKWISE_API/v1/discover/<id>/interactions" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "event": "<event>" }'
Response
204 No Content

No body. The resource is gone.
POST/v1/discover/{id}/save

Save a discovery item to the library

discover:writelinks:write

Requires both discover:write and links:write.

Returns

  • 201Saved
  • 402A plan quota is exhausted
Request
curl -s -X POST "$LINKWISE_API/v1/discover/<id>/save" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}

Feeds

4 endpoints

RSS subscriptions.

GET/v1/feeds

List subscriptions

feeds:read

Query parameters

cursorstringoptional

From a previous response's meta.next_cursor. Opaque: do not construct or parse one. Takes precedence over limit.

limitintegeroptionaldefault 25

Items per page.

Returns

  • 200A page of subscribed feeds
Request
curl -s "$LINKWISE_API/v1/feeds" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
POST/v1/feeds

Subscribe to a feed

feeds:write

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

  • 201Subscribed
  • 400invalid_requestNo feed could be found at that address
  • 402A plan quota is exhausted
Request
curl -s -X POST "$LINKWISE_API/v1/feeds" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
DELETE/v1/feeds/{id}

Unsubscribe

feeds:write

Removes your subscription. The feed itself is shared and is not deleted.

Returns

  • 204Unsubscribed
  • 404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Request
curl -s -X DELETE "$LINKWISE_API/v1/feeds/<id>" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
204 No Content

No body. The resource is gone.
GET/v1/feeds/export

Export subscriptions as OPML

feeds:read

Standard OPML 2.0, importable by any other reader.

Returns

  • 200An OPML document
Request
curl -s "$LINKWISE_API/v1/feeds/export" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}

AI

4 endpoints

Chat over a link, a collection or the whole library, plus speech.

POST/v1/chat

Chat over your library

ai:chatPro

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

querystringrequired
scopestringoptional

One of library, link, collection

context_iduuidoptional

Required when scope is link or collection.

conversation_iduuidoptional

Omit to start a new conversation.

Returns

  • 200An SSE stream of tokens and tool events
  • 400invalid_requestMalformed or missing parameters
  • 402The feature requires a Pro subscription
  • 500Not available with a personal access token
Request
curl -s -X POST "$LINKWISE_API/v1/chat" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "query": "<query>" }'
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
GET/v1/conversations

List conversations

ai:chat

Query parameters

cursorstringoptional

From a previous response's meta.next_cursor. Opaque: do not construct or parse one. Takes precedence over limit.

limitintegeroptionaldefault 25

Items per page.

qstringoptional

Substring filter on conversation title.

context_typestringoptional

One of link, collection, library

Returns

  • 200A page of conversations
Request
curl -s "$LINKWISE_API/v1/conversations" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
GET/v1/conversations/{id}/messages

Get conversation messages

ai:chat

Query parameters

cursorstringoptional

From a previous response's meta.next_cursor. Opaque: do not construct or parse one. Takes precedence over limit.

limitintegeroptionaldefault 25

Items per page.

Returns

  • 200A page of messages
Request
curl -s "$LINKWISE_API/v1/conversations/<id>/messages" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
POST/v1/tts

Synthesise speech

tts:synthesizePro

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

textstringrequired
linkIduuidoptional
voiceIdstringoptional
speednumberoptional
includeSpeechMarksbooleanoptional
paragraphIndexintegeroptional

Returns

  • 200Synthesised audio
  • 402The feature requires a Pro subscription
Request
curl -s -X POST "$LINKWISE_API/v1/tts" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "text": "<text>" }'
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}

Account

4 endpoints
GET/v1/me

Get the current account

profile:read

Identity, plan and how this request authenticated. Deliberately narrow: it does not return deletion tokens, suspension state or onboarding internals.

Returns

  • 200The account
Request
curl -s "$LINKWISE_API/v1/me" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
PATCH/v1/me

Update the profile

profile:write

Username uniqueness and format are validated server-side.

Body

usernamestringoptional
avatar_urlurioptional

Returns

  • 200Updated
  • 409conflictAlready exists
Request
curl -s -X PATCH "$LINKWISE_API/v1/me" \
  -H "Authorization: Bearer $LINKWISE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "username": "<username>", "avatar_url": "<avatar_url>" }'
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
GET/v1/me/usage

Get credit usage

profile:read

Returns

  • 200Credits assigned, used and remaining
Request
curl -s "$LINKWISE_API/v1/me/usage" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
GET/v1/me/limits

Check a plan limit

profile:read

Ask whether an action is currently allowed, before attempting it.

Query parameters

actionstringoptionaldefault add_link

One of add_link, add_collection, subscribe_feed

collection_iduuidoptional

Relevant to per-collection link limits.

Returns

  • 200Whether the action is allowed, and the relevant quota
Request
curl -s "$LINKWISE_API/v1/me/limits" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}

Tokens

3 endpoints

Personal access token management. Session JWT only.

GET/v1/tokens

List personal access tokens

Session only

Session JWT only. Never returns token values, only their display prefixes.

Returns

  • 200Active tokens
  • 403forbiddenCalled with a personal access token
Request
curl -s "$LINKWISE_API/v1/tokens" \
  -H "Authorization: Bearer $LINKWISE_SESSION_JWT"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}
POST/v1/tokens

Create a personal access token

Session only

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

namestringrequired
scopesarray of stringoptional
expires_in_daysintegeroptional

Omit for a token that never expires.

Returns

  • 201Created. `token` appears only in this response.
  • 400invalid_requestMalformed or missing parameters
Request
curl -s -X POST "$LINKWISE_API/v1/tokens" \
  -H "Authorization: Bearer $LINKWISE_SESSION_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "name": "<name>" }'
Response
{
  "data": {
    "id": "...",
    "created_at": "..."
  }
}
DELETE/v1/tokens/{id}

Revoke a token

Session only

Session JWT only. Takes effect immediately.

Returns

  • 204Revoked
  • 404not_foundNo such resource. Also returned when a resource exists but belongs to someone else, so ids cannot be probed for existence.
Request
curl -s -X DELETE "$LINKWISE_API/v1/tokens/<id>" \
  -H "Authorization: Bearer $LINKWISE_SESSION_JWT"
Response
204 No Content

No body. The resource is gone.

Meta

1 endpoints
GET/v1/health

Liveness check

The only unauthenticated endpoint, so uptime checks need no credential.

Returns

  • 200Service is up
Request
curl -s "$LINKWISE_API/v1/health" \
  -H "Authorization: Bearer $LINKWISE_TOKEN"
Response
{
  "data": [
    { "id": "...", "created_at": "..." }
  ],
  "meta": {
    "next_cursor": "eyJ...",
    "has_more": true
  }
}