Skip to main content
LinkLynx

Developers

LinkLynx Developer API (beta)

Manage your page from code, or from an AI assistant. The API reads your page, rows and cards, and with the write scope it adds, edits, schedules, reorders and deletes them. It is a beta, so details can change, and it is included with Premium and Studio.

Quickstart

  1. Open the editor, choose Features, then Developer API, and create a token with the read scope. Copy it right away. It is shown once.
  2. Send it as a Bearer token.
curl
curl https://www.linklynx.bio/api/v1/page \
  -H "Authorization: Bearer llk_..."

Always use www.linklynx.bio. The bare domain redirects, and many clients drop the Authorization header when they follow a redirect.

New to the API? Follow the step-by-step guide, from your first token to changing a card.

Authentication and permissions

Every request carries a personal access token: Authorization: Bearer llk_.... Tokens start with llk_. Treat one like a password and keep it on your server. Never put it in a browser, a public repo or a client app. The API sends no CORS headers on purpose.

Each token has scopes. Pick only what the code needs.

ScopeWhat it allowsStatus
readSee your page, rows and cards.Live
writeAdd, edit, schedule, reorder and delete cards and rows, and edit your name and bio.Live
videoGenerate cinematic videos.Coming soon
  • Expiry: 30 days, 90 days, 1 year, never. The default is 90 days.
  • Revoke a token in the editor at any time. It stops working right away.
  • Up to 10 active tokens per page.
  • Premium or Studio is required. If your plan drops to Free, requests answer 403 plan_required until it is back. Your tokens are kept.

Rate limits and errors

60 requests per minute per token. Past that you get 429 with a Retry-After header. The count is kept per server instance, so treat it as approximate. Responses are never cached.

Every error has the same shape:

error
{ "error": { "code": "token_expired", "message": "This token has expired." } }
CodeStatusMeaning
missing_token401No Authorization header, or it is not a Bearer token.
invalid_token401The token is not recognized. Check for typos or a truncated copy.
token_revoked401The token was revoked in the editor.
token_expired401The token passed its expiry date. Create a new one.
plan_required403The page is no longer on Premium or Studio. Tokens are kept and work again when the plan is back.
insufficient_scope403The token does not have the scope this endpoint needs.
invalid_parameter400A query parameter is missing or malformed.
invalid_body400The JSON body is malformed, has an unknown field, or a value is out of range. The message names the field.
limit_reached403Your plan limit for rows, items per row or total items is reached. Upgrade or delete something first.
not_found404The thing you asked for does not exist on your page.
row_not_empty409The row still has cards. Move or delete them, or pass force=true to delete the row with its cards.
rate_limited429More than 60 requests in a minute for this token. Wait for Retry-After seconds.
internal_error500Something went wrong on our side. Try again.

Endpoints

Base URL https://www.linklynx.bio/api/v1. Responses are { "data": ... } with snake_case fields. This reference is generated from the OpenAPI spec, so it always matches what is live.

GET/api/v1/page

Your page

Scope: read.

Response fields

FieldTypeAlways present
data.namestringYes
data.usernamestringYes
data.biostringYes
data.avatar_urlstring (uri) | nullYes
data.planstring: "free", "premium", "studio"Yes
data.public_urlstring (uri)Yes
GET /page response
{
  "data": {
    "name": "Mara Quinn",
    "username": "maraquinn",
    "bio": "Photographer. Coffee first.",
    "avatar_url": "https://www.linklynx.bio/avatars/maraquinn.jpg",
    "plan": "premium",
    "public_url": "https://www.linklynx.bio/maraquinn"
  }
}

Other statuses: 401, 403, 429. See errors.

PATCH/api/v1/page

Change your display name and bio

Scope: write. Send name and/or bio. The username and avatar cannot be changed through the API.

Request body (JSON)

FieldTypeRequiredDescription
namestringNo
biostringNo
PATCH /page request
{
  "name": "Rafael Valle",
  "bio": "Filmmaker and cat person."
}

Response fields

FieldTypeAlways present
data.namestringYes
data.usernamestringYes
data.biostringYes
data.avatar_urlstring (uri) | nullYes
data.planstring: "free", "premium", "studio"Yes
data.public_urlstring (uri)Yes
PATCH /page response
{
  "data": {
    "name": "Mara Quinn",
    "username": "maraquinn",
    "bio": "Photographer. Coffee first.",
    "avatar_url": "https://www.linklynx.bio/avatars/maraquinn.jpg",
    "plan": "premium",
    "public_url": "https://www.linklynx.bio/maraquinn"
  }
}

Other statuses: 400, 401, 403, 404, 429. See errors.

GET/api/v1/rows

Rows in display order, with card counts

Scope: read. Card counts include scheduled cards.

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.titlestringYes
data.orderintegerYes
data.card_countintegerYes
data.updated_atstring (date-time)Yes
GET /rows response
{
  "data": [
    {
      "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
      "title": "Latest film",
      "order": 0,
      "card_count": 4,
      "updated_at": "updated_at"
    }
  ]
}

Other statuses: 401, 403, 429. See errors.

POST/api/v1/rows

Create a row

Scope: write. The new row goes last. Plan row limits apply (403 limit_reached).

Request body (JSON)

FieldTypeRequiredDescription
titlestringYes
POST /rows request
{
  "title": "Music"
}

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.titlestringYes
data.orderintegerYes
data.card_countintegerYes
data.updated_atstring (date-time)Yes
POST /rows response
{
  "data": {
    "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
    "title": "Latest film",
    "order": 0,
    "card_count": 4,
    "updated_at": "updated_at"
  }
}

Other statuses: 400, 401, 403, 429. See errors.

GET/api/v1/cards

Cards in display order

Scope: read. Includes scheduled cards (status "scheduled"), which visitors do not see yet.

Query parameters

NameTypeRequiredDescription
row_idstring (uuid)NoOnly the cards of this row.

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.row_idstring (uuid)Yes
data.titlestringYes
data.subtitlestringYes
data.hrefstringYes
data.kindstring: "link", "email"Yes
data.thumbnailstring (uri) | nullYes
data.videoobject | nullYes
data.video.urlstring (uri)Yes
data.video.poster_urlstring (uri) | nullYes
data.orderintegerYes
data.pinnedbooleanYes
data.statusstring: "published", "scheduled"Yes
data.scheduled_atstring (date-time) | nullYes
data.created_atstring (date-time)Yes
data.updated_atstring (date-time)Yes
GET /cards response
{
  "data": [
    {
      "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
      "row_id": "5e2d9a7b-2f4c-4d8e-8a31-9c6b0e1f4d22",
      "title": "Latest film",
      "subtitle": "Watch the trailer",
      "href": "https://example.com/film",
      "kind": "link",
      "thumbnail": "https://www.linklynx.bio/media/film.jpg",
      "video": {
        "url": "https://www.linklynx.bio/media/film.mp4",
        "poster_url": "https://www.linklynx.bio/media/film-poster.jpg"
      },
      "order": 0,
      "pinned": false,
      "status": "published",
      "scheduled_at": null,
      "created_at": "2026-10-01T09:30:00Z",
      "updated_at": "updated_at"
    }
  ]
}

Other statuses: 400, 401, 403, 404, 429. See errors.

POST/api/v1/cards

Create a card from a link

Scope: write. Fetches the page at url for a title, description and image, the same autofill as the editor's quick add, so the call can take a few seconds. Fields you send win over what was found. If the page gives no title, the host name is used. The card goes last in row_id, or in your first row (a row called My Links is created if you have none). Plan item limits apply (403 limit_reached). The thumbnail is copied into LinkLynx storage right after the response.

Request body (JSON)

FieldTypeRequiredDescription
urlstringYeshttp or https link. A missing scheme becomes https.
row_idstring (uuid)NoDefaults to your first row.
titlestringNoDefaults to the page's title, then the host name.
subtitlestringNoDefaults to the page's description.
POST /cards request
{
  "url": "https://example.com/spring",
  "title": "Spring collection"
}

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.row_idstring (uuid)Yes
data.titlestringYes
data.subtitlestringYes
data.hrefstringYes
data.kindstring: "link", "email"Yes
data.thumbnailstring (uri) | nullYes
data.videoobject | nullYes
data.video.urlstring (uri)Yes
data.video.poster_urlstring (uri) | nullYes
data.orderintegerYes
data.pinnedbooleanYes
data.statusstring: "published", "scheduled"Yes
data.scheduled_atstring (date-time) | nullYes
data.created_atstring (date-time)Yes
data.updated_atstring (date-time)Yes
POST /cards response
{
  "data": {
    "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
    "row_id": "5e2d9a7b-2f4c-4d8e-8a31-9c6b0e1f4d22",
    "title": "Latest film",
    "subtitle": "Watch the trailer",
    "href": "https://example.com/film",
    "kind": "link",
    "thumbnail": "https://www.linklynx.bio/media/film.jpg",
    "video": {
      "url": "https://www.linklynx.bio/media/film.mp4",
      "poster_url": "https://www.linklynx.bio/media/film-poster.jpg"
    },
    "order": 0,
    "pinned": false,
    "status": "published",
    "scheduled_at": null,
    "created_at": "2026-10-01T09:30:00Z",
    "updated_at": "updated_at"
  }
}

Other statuses: 400, 401, 403, 404, 429. See errors.

PATCH/api/v1/rows/{id}

Rename a row

Scope: write. A row on another page is a 404.

Query parameters

NameTypeRequiredDescription
idstring (uuid)YesThe row id.

Request body (JSON)

FieldTypeRequiredDescription
titlestringYes
PATCH /rows/{id} request
{
  "title": "Videos"
}

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.titlestringYes
data.orderintegerYes
data.card_countintegerYes
data.updated_atstring (date-time)Yes
PATCH /rows/{id} response
{
  "data": {
    "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
    "title": "Latest film",
    "order": 0,
    "card_count": 4,
    "updated_at": "updated_at"
  }
}

Other statuses: 400, 401, 403, 404, 429. See errors.

DELETE/api/v1/rows/{id}

Delete a row

Scope: write. A row that still has cards is refused with 409 row_not_empty. Pass force=true to delete it together with its cards (this cannot be undone). The editor asks for confirmation before it does the same.

Query parameters

NameTypeRequiredDescription
idstring (uuid)YesThe row id.
forcebooleanNoDelete the row even if it has cards.

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.deletedbooleanYes
DELETE /rows/{id} response
{
  "data": {
    "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
    "deleted": true
  }
}

Other statuses: 400, 401, 403, 404, 409, 429. See errors.

POST/api/v1/rows/reorder

Reorder rows

Scope: write. Send every row id exactly once, in the new order. A missing, repeated or unknown id is a 400 invalid_body and nothing changes.

Request body (JSON)

FieldTypeRequiredDescription
row_idsarrayYesEvery row id of the page, once each, in the new order.
POST /rows/reorder request
{
  "row_ids": [
    "0a7c4d9e-5b61-4f0e-8d2a-3c9b1e7f6a21",
    "7b2e9f10-3c44-4d8b-a1f5-9e6d2c8b4a03"
  ]
}

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.titlestringYes
data.orderintegerYes
data.card_countintegerYes
data.updated_atstring (date-time)Yes
POST /rows/reorder response
{
  "data": [
    {
      "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
      "title": "Latest film",
      "order": 0,
      "card_count": 4,
      "updated_at": "updated_at"
    }
  ]
}

Other statuses: 400, 401, 403, 429. See errors.

PATCH/api/v1/cards/{id}

Change a card

Scope: write. Send only the fields to change. thumbnail_url must be https and is copied into LinkLynx storage like an image pasted in the editor; null removes it. scheduled_at in the future hides the card from visitors, the sitemap, llms.txt, JSON-LD, social previews and Ask the lynx until then; null (or a past time) makes it live. row_id moves the card to the end of another row (plan row limits apply). Email signup cards only accept title, subtitle, pinned and row_id. A card on another page is a 404.

Query parameters

NameTypeRequiredDescription
idstring (uuid)YesThe card id.

Request body (JSON)

FieldTypeRequiredDescription
titlestringNo
subtitlestringNo
hrefstringNohttp or https link.
thumbnail_urlstring (uri) | nullNohttps image URL, copied into LinkLynx storage. null removes the image.
pinnedbooleanNoPinned cards keep their place when Smart Layout reorders.
scheduled_atstring (date-time) | nullNoISO 8601. A future time hides the card until then. null makes it live.
row_idstring (uuid)NoMove the card to the end of this row.
PATCH /cards/{id} request
{
  "title": "Spring collection (new)",
  "scheduled_at": "2026-12-01T09:00:00Z",
  "pinned": true
}

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.row_idstring (uuid)Yes
data.titlestringYes
data.subtitlestringYes
data.hrefstringYes
data.kindstring: "link", "email"Yes
data.thumbnailstring (uri) | nullYes
data.videoobject | nullYes
data.video.urlstring (uri)Yes
data.video.poster_urlstring (uri) | nullYes
data.orderintegerYes
data.pinnedbooleanYes
data.statusstring: "published", "scheduled"Yes
data.scheduled_atstring (date-time) | nullYes
data.created_atstring (date-time)Yes
data.updated_atstring (date-time)Yes
PATCH /cards/{id} response
{
  "data": {
    "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
    "row_id": "5e2d9a7b-2f4c-4d8e-8a31-9c6b0e1f4d22",
    "title": "Latest film",
    "subtitle": "Watch the trailer",
    "href": "https://example.com/film",
    "kind": "link",
    "thumbnail": "https://www.linklynx.bio/media/film.jpg",
    "video": {
      "url": "https://www.linklynx.bio/media/film.mp4",
      "poster_url": "https://www.linklynx.bio/media/film-poster.jpg"
    },
    "order": 0,
    "pinned": false,
    "status": "published",
    "scheduled_at": null,
    "created_at": "2026-10-01T09:30:00Z",
    "updated_at": "updated_at"
  }
}

Other statuses: 400, 401, 403, 404, 429. See errors.

DELETE/api/v1/cards/{id}

Delete a card

Scope: write. Also removes its uploaded media and settles any video job in progress, as in the editor.

Query parameters

NameTypeRequiredDescription
idstring (uuid)YesThe card id.

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.deletedbooleanYes
DELETE /cards/{id} response
{
  "data": {
    "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
    "deleted": true
  }
}

Other statuses: 401, 403, 404, 429. See errors.

POST/api/v1/cards/reorder

Reorder the cards of a row

Scope: write. Send every card id of the row exactly once, in the new order. A missing, repeated or foreign id is a 400 invalid_body and nothing changes. With Smart Layout on, your order is kept as your manual order.

Request body (JSON)

FieldTypeRequiredDescription
row_idstring (uuid)Yes
card_idsarrayYesEvery card id of the row, once each, in the new order.
POST /cards/reorder request
{
  "row_id": "0a7c4d9e-5b61-4f0e-8d2a-3c9b1e7f6a21",
  "card_ids": [
    "6f1d1c1e-2b7a-4a43-9c3e-1f2f4d6b8a10",
    "c2d4e6f8-1a3b-4c5d-8e7f-0a1b2c3d4e5f"
  ]
}

Response fields

FieldTypeAlways present
data.idstring (uuid)Yes
data.row_idstring (uuid)Yes
data.titlestringYes
data.subtitlestringYes
data.hrefstringYes
data.kindstring: "link", "email"Yes
data.thumbnailstring (uri) | nullYes
data.videoobject | nullYes
data.video.urlstring (uri)Yes
data.video.poster_urlstring (uri) | nullYes
data.orderintegerYes
data.pinnedbooleanYes
data.statusstring: "published", "scheduled"Yes
data.scheduled_atstring (date-time) | nullYes
data.created_atstring (date-time)Yes
data.updated_atstring (date-time)Yes
POST /cards/reorder response
{
  "data": [
    {
      "id": "0b6c1f5e-6c1d-4a40-9d4e-3f2a1c9e7a10",
      "row_id": "5e2d9a7b-2f4c-4d8e-8a31-9c6b0e1f4d22",
      "title": "Latest film",
      "subtitle": "Watch the trailer",
      "href": "https://example.com/film",
      "kind": "link",
      "thumbnail": "https://www.linklynx.bio/media/film.jpg",
      "video": {
        "url": "https://www.linklynx.bio/media/film.mp4",
        "poster_url": "https://www.linklynx.bio/media/film-poster.jpg"
      },
      "order": 0,
      "pinned": false,
      "status": "published",
      "scheduled_at": null,
      "created_at": "2026-10-01T09:30:00Z",
      "updated_at": "updated_at"
    }
  ]
}

Other statuses: 400, 401, 403, 404, 429. See errors.

Use with your AI agent (MCP)

LinkLynx has a remote MCP server, so your AI agent can look after your page for you: review it, rewrite titles, add and schedule cards, reorder rows. It uses the same personal access token and the same rules as the REST API, so your plan limits and the token's scopes apply.

Endpoint: https://www.linklynx.bio/api/mcp. Create a token in the editor first. Use the read scope to let it look, add write to let it change things.

Works with any MCP-compatible agent. Claude Code, claude.ai and Claude Desktop are documented below, and other MCP clients that support remote HTTP servers with custom headers, such as Cursor and VS Code, connect with the endpoint above and your token as the Authorization header. ChatGPT is not supported yet, because it needs OAuth sign-in, which is coming later.

Claude Code

Claude Code
claude mcp add --transport http linklynx https://www.linklynx.bio/api/mcp --header "Authorization: Bearer llk_..."

claude.ai and Claude Desktop

  1. Open Settings, then Connectors, then add a custom connector.
  2. Enter the URL https://www.linklynx.bio/api/mcp.
  3. Add a request header named Authorization with the value Bearer llk_..., using your token.

Ask for something like “review my LinkLynx page and suggest a stronger first row”. The redesign_my_page prompt walks your agent through a review and a plan, and it waits for your yes before changing anything.

OpenAI Codex (CLI and IDE extension)

Codex works today with a token. Put your token in an environment variable named LINKLYNX_TOKEN, then add this to your Codex config.toml. ChatGPT, the chat app, is a different product that needs OAuth sign-in, which is coming later.

config.toml
[mcp_servers.linklynx]
url = "https://www.linklynx.bio/api/mcp"
bearer_token_env_var = "LINKLYNX_TOKEN"

Other MCP clients

Any client that supports a remote HTTP MCP server with custom headers works the same way. Add a server with the URL https://www.linklynx.bio/api/mcp and an Authorization header with the value Bearer llk_..., using your token. In Cursor that is a url plus headers entry in your MCP config. In VS Code it is an http server with headers.

What your agent can do

ToolScopeWhat it does
get_pagereadGet page
list_cardsreadList cards
get_cardreadGet card
get_analyticsreadGet analytics
update_pagewriteUpdate page text
add_cardwriteAdd card
update_cardwriteUpdate card
delete_cardwriteDelete carddeletes
reorder_cardswriteReorder cards
create_rowwriteCreate row
rename_rowwriteRename row
reorder_rowswriteReorder rows
delete_rowwriteDelete rowdeletes

Your agent can also read linklynx://page, a plain outline of your rows and cards in order, so it knows the page before it edits. Video generation is not part of this yet.

Safety

  • The token is the control. A read token cannot change anything, and a write token can only touch your own page. Revoke it in the editor and your agent stops at once.
  • Your agent is told to show you a plan before editing and to ask before any delete. Tools that delete are marked as destructive, so clients ask for approval too.
  • Every change your agent makes is recorded in the same audit log as API writes. Your plan limits apply, and cards you schedule stay hidden until their time.
  • Only connect an assistant you trust. Anything it can read from the web can try to steer it, so skim what it plans to change.

ChatGPT: coming soon. It needs sign-in instead of a token, and so does adding LinkLynx from the claude.ai connector directory.

Versioning and API changelog

  • The major version is in the URL: /api/v1.
  • v1 only gets additive, non-breaking changes, such as new endpoints, new optional parameters and new response fields. Write your client to ignore fields it does not know.
  • A breaking change ships as /api/v2. v1 keeps working for at least 6 months after that, and its responses carry Deprecation and Sunset headers.
  • While this is a beta, fields can still change before 1.0. Every change is listed below. The product changelog lives on the What's new page.

API changelog

  1. added

    MCP server

    There is now a remote MCP server at https://www.linklynx.bio/api/mcp (Streamable HTTP, stateless). It takes the same personal access token as the REST API, as a Bearer header, with the same scopes, plan check, expiry, revocation and rate limit. Read tools: get_page, list_cards, get_card, get_analytics. Write tools: update_page, add_card, update_card, delete_card, reorder_cards, create_row, rename_row, reorder_rows, delete_row. Writes are recorded in the audit log with the path /mcp:<tool>. Errors keep the same codes and messages. See the Use with your AI agent section.

  2. added

    Write endpoints for cards, rows and page text

    With the write scope you can now create, change, schedule, pin, move, reorder and delete cards (POST /cards, PATCH and DELETE /cards/{id}, POST /cards/reorder), manage rows (POST /rows, PATCH and DELETE /rows/{id}, POST /rows/reorder) and change your display name and bio (PATCH /page). New error codes: invalid_body, limit_reached and row_not_empty. Cards and rows now include updated_at, which needs the new database migration.

  3. added

    v1 beta: personal access tokens, GET /page, /rows, /cards

    Premium and Studio pages can create personal access tokens in the editor and read their page, rows and cards. Read scope only. Write and video scopes are listed but not live yet.

Code samples

Keep the token in an environment variable, never in source. Check the status and read error.code when it is not 200.

curl
curl https://www.linklynx.bio/api/v1/page \
  -H "Authorization: Bearer llk_..."
JavaScript
const res = await fetch("https://www.linklynx.bio/api/v1/cards", {
  headers: { Authorization: `Bearer ${process.env.LINKLYNX_TOKEN}` },
});
if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}
const { data: cards } = await res.json();
console.log(cards.length, "cards");
Python
import os
import requests

res = requests.get(
    "https://www.linklynx.bio/api/v1/rows",
    headers={"Authorization": f"Bearer {os.environ['LINKLYNX_TOKEN']}"},
    timeout=10,
)
res.raise_for_status()
for row in res.json()["data"]:
    print(row["title"], row["card_count"])

Questions, or something you wish the API did? Tell me.