Linky logoLinky
Docs navigation

Reference — API

API reference

Every public Linky route lives under /api. All mutating routes return Content-Type: application/json; error bodies share a common { error, code } shape.

POST /api/links (public)

Create a new Linky. Anonymous callers get a claim token; signed-in callers get a Linky attributed to their active Linky organization (or their user account, when no organization is active).

POST /api/links
content-type: application/json
Linky-Client: cursor/skill-v1        # optional

{
  "urls": ["https://example.com", "https://example.org"],
  "source": "agent",
  "title": "Release review bundle",
  "description": "Open everything needed for the 2026.04 standup.",
  "urlMetadata": [
    { "note": "PR under review", "tags": ["eng"] },
    { "note": "Preview deploy", "openPolicy": "desktop" }
  ],
  "email": "alice@example.com",
  "resolutionPolicy": {
    "version": 1,
    "rules": [
      {
        "name": "Engineering team",
        "when": { "op": "endsWith", "field": "emailDomain", "value": "acme.com" },
        "tabs": [{ "url": "https://linear.app/acme/my-issues" }]
      }
    ]
  }
}
{
  "slug": "x8q2m4k",
  "url": "https://getalinky.com/l/x8q2m4k",
  "claimUrl": "https://getalinky.com/claim/B6p…",
  "claimToken": "B6p…",
  "claimExpiresAt": "2026-05-16T12:00:00.000Z",
  "warning": "Save claimToken and claimUrl now — they are returned only once and cannot be recovered."
}

Signed-in responses omit every claim* field and the warning. See Create for the full request-body table and error codes.

GET /api/links/:slug (view+)

Read a single Linky by slug. Returns the full DTO including urls, urlMetadata, owner, resolutionPolicy, and timestamps. Any role with view access can read. Bearer callers need the links:read scope.

{
  "slug": "x8q2m4k",
  "urls": ["https://example.com", "https://example.org"],
  "urlMetadata": [
    { "note": "PR under review", "tags": ["eng"] },
    { "note": "Preview deploy", "openPolicy": "desktop" }
  ],
  "title": "Release review bundle",
  "description": "Open everything needed for the 2026.04 standup.",
  "owner": { "type": "user", "userId": "user_…" },
  "createdAt": "2026-04-16T12:00:00.000Z",
  "updatedAt": "2026-04-16T12:00:00.000Z",
  "source": "agent",
  "metadata": null,
  "resolutionPolicy": { "version": 1, "rules": [] }
}

Anonymous viewers see nothing here — this route is owner-scoped, not the public launcher. The public read path is GET /l/:slug (HTML). Soft-deleted Linkies return 404.

PATCH /api/links/:slug (editor+)

Edit a Linky. All fields optional; at least one required. Every edit — including policy edits — is saved as a new version, so previous states are always recoverable via GET /api/links/:slug/versions.

On org-owned bundles, editor and admin roles can PATCH; viewer cannot. Bearer callers need the links:write scope. See Access control for the full matrix.

PATCH /api/links/:slug
content-type: application/json
# owner-only — signed-in Linky session required

{
  "title": "Release review (v2)",
  "description": null,
  "urls": ["https://example.com"],
  "urlMetadata": [{ "note": "rebuilt" }],
  "resolutionPolicy": {
    "version": 1,
    "rules": [
      {
        "name": "Engineering team",
        "showBadge": true,
        "when": {
          "op": "and",
          "of": [
            { "op": "signedIn" },
            { "op": "endsWith", "field": "emailDomain", "value": "acme.com" }
          ]
        },
        "tabs": [
          { "url": "https://linear.app/acme/my-issues", "note": "Your queue" },
          { "url": "https://github.com/acme/app/pulls?q=author:@me" }
        ]
      }
    ]
  }
}

Send "resolutionPolicy": null to clear the policy. Omit the field to leave it untouched. Anonymous Linkies (both owner columns NULL) always reject — claim first.

DELETE /api/links/:slug (admin-only)

Soft-deletes the Linky. The public /l/:slug launcher returns 404 afterwards; the version history stays intact so you can audit what the bundle pointed at.

On org-owned bundles, only the admin role can delete. Editors cannot. Bearer callers need the links:write scope plus admin role — an editor-scoped key never deletes.

GET /api/me/links (signed-in, view+)

Paginated list of the active subject's launch bundles. Query params: limit (default 20, max 100), offset (default 0). Bearer callers need the links:read scope.

{
  "items": [
    {
      "slug": "x8q2m4k",
      "title": "Release review bundle",
      "description": null,
      "urls": ["https://example.com", "https://example.org"],
      "urlMetadata": [{}, {}],
      "owner": { "type": "user", "userId": "user_…" },
      "createdAt": "2026-04-16T12:00:00.000Z",
      "updatedAt": "2026-04-16T12:00:00.000Z",
      "source": "agent"
    }
  ],
  "nextOffset": 20
}

GET /api/links/:slug/versions (view+)

Every edit is kept forever as a new version. This endpoint returns every prior snapshot for the Linky, newest first. Any role with view access can read this — editors don't need edit rights to see what changed.

{
  "items": [
    {
      "versionId": "ver_…",
      "createdAt": "2026-04-16T12:00:00.000Z",
      "title": "Release review bundle",
      "description": null,
      "urls": ["https://example.com"],
      "urlMetadata": [{}],
      "resolutionPolicy": { "version": 1, "rules": [] }
    }
  ]
}

GET /api/links/:slug/insights (view+)

Owner-side analytics. Returns view + Open All counts, a daily series, and a per-rule breakdown so you can see whether your personalized Linky is reaching the right audience. Any role with view access can read — viewer / editor / admin all get the numbers. Bearer callers need the links:read scope.

GET /api/links/:slug/insights?range=30d
# any role with view access; read-only — no request body

# range accepts 7d, 30d (default), or 90d
{
  "slug": "x8q2m4k",
  "range": {
    "from": "2026-03-19T00:00:00.000Z",
    "to":   "2026-04-18T00:00:00.000Z"
  },
  "totals": {
    "views": 412,
    "uniqueViewerDays": 287,
    "openAllClicks": 198,
    "openAllRate": 0.481
  },
  "byRule": [
    {
      "ruleId": "01J...",
      "ruleName": "Engineering team",
      "views": 164,
      "openAllClicks": 102,
      "openAllRate": 0.622
    },
    {
      "ruleId": null,
      "ruleName": "Fallthrough",
      "views": 186,
      "openAllClicks": 56,
      "openAllRate": 0.301
    }
  ],
  "series": [
    { "day": "2026-04-11", "views": 18, "openAllClicks": 9 },
    { "day": "2026-04-12", "views": 22, "openAllClicks": 12 }
  ]
}
  • range accepts 7d, 30d (default), or 90d. Anything else silently clamps to the default.
  • uniqueViewerDays counts distinct per-day viewer hashes. Cross-day identity is not recoverable by design — the daily salt rotates.
  • byRule labels resolve from the current policy. A rule you deleted yesterday renders as "(removed rule)" so history survives policy edits. Fallthrough (no rule matched) is the bucket with ruleId: null.
  • No viewer identity leaves the table. No destination-tab pings. See Access control for the full trust posture.

POST /api/links/:slug/events (public)

The launcher page fires this endpoint for every Open All click. You will rarely call it directly — documenting it here because it shows up in browser network traces and in rate-limit budgets. Returns 204 No Contenton every non-exceptional outcome (including unknown slugs — we don't leak existence through this route).

POST /api/links/:slug/events
content-type: application/json

{
  "kind": "open_all",
  "matchedRuleId": "01J..."   # optional; pass null for fallthrough
}

Rate-limited per IP, same bucket as POST /api/links. Best-effort: a DB outage drops the event and returns 204 anyway — the launcher's real job (opening tabs) has already happened by the time the ping lands.

POST /api/me/keys — mint a scoped API key (keys:admin)

Mint an API key for automation. Org admins mint team keys; individual users mint personal keys. Scope is locked at mint — to change it, revoke and re-issue. Three presets:

  • links:read — list, view, read insights. Safe for LLM context.
  • links:write (default) — everything read can do, plus PATCH. Cannot DELETE — that needs admin role.
  • keys:admin — everything above, plus minting and revoking other keys. Treat like a root credential.

rateLimitPerHour caps the authenticated requests this key can make per 60-minute window. Defaults to 1000; valid range 0–100000 where 0 disables the limit (reserve for admin / internal keys). Exhausted keys return HTTP 429 with retryAfterSeconds in the JSON body. See Limits.

POST /api/me/keys
content-type: application/json
# org subjects: admin role required

{
  "name": "release-bot",
  "scopes": ["links:read"],       # optional; defaults to ["links:write"]
  "rateLimitPerHour": 200         # optional; 0 = unlimited, default 1000, cap 100000
}
{
  "apiKey": {
    "id": 42,
    "name": "release-bot",
    "scope": "user",
    "scopes": ["links:read"],
    "keyPrefix": "lkyu_a1b2c3d4",
    "rateLimitPerHour": 200,
    "createdAt": "2026-04-18T12:00:00.000Z",
    "lastUsedAt": null,
    "revokedAt": null
  },
  "rawKey": "lkyu_a1b2c3d4.shown-once-cannot-be-recovered",
  "warning": "Save this API key now — it is shown only once and cannot be recovered."
}

The rawKey is returned once. Paste it into your secret store immediately — no endpoint reveals it again.

GET /api/me/keys — list + whoami (keys:admin)

Returns every key owned by the caller (user or active org), plus the resolved subject descriptor. Revoked keys are included with revokedAtpopulated so you can audit past credentials. Raw secrets are never re-returned. The CLI's linky auth whoami uses this endpoint as the auth probe.

{
  "apiKeys": [
    {
      "id": 42,
      "name": "release-bot",
      "scope": "user",
      "scopes": ["links:read"],
      "keyPrefix": "lkyu_a1b2c3d4",
      "rateLimitPerHour": 200,
      "createdAt": "2026-04-18T12:00:00.000Z",
      "lastUsedAt": "2026-04-19T09:12:33.000Z",
      "revokedAt": null
    }
  ],
  "subject": { "type": "user", "userId": "user_…" }
}

DELETE /api/me/keys — revoke a key (keys:admin)

Revokes the named key by numeric id, passed as a query parameter (not a path segment). Idempotent: already-revoked keys return their existing revokedAt. Ownership is enforced server-side — you can only revoke keys your own subject owns.

DELETE /api/me/keys?id=42
# no request body — id is passed as a query parameter
# org subjects: admin role required; bearer callers need keys:admin scope
{
  "apiKey": {
    "id": 42,
    "name": "release-bot",
    "scope": "user",
    "scopes": ["links:read"],
    "keyPrefix": "lkyu_a1b2c3d4",
    "rateLimitPerHour": 200,
    "createdAt": "2026-04-18T12:00:00.000Z",
    "lastUsedAt": "2026-04-19T09:12:33.000Z",
    "revokedAt": "2026-04-19T10:00:00.000Z"
  }
}

POST /api/mcp — agent-facing transport

Every authed route in this reference is also exposed as an MCP tool via Streamable-HTTP at /api/mcp. Agents in Cursor, Claude Desktop, Codex, Continue, and Cline connect with a bearer token in the Authorization header; no additional plumbing is needed. See MCP for the tool catalog and paste-ready mcp.json config for each harness.

Webhooks

POST /api/webhooks/clerk and POST /api/webhooks/stripe are signature-verified service endpoints called by Clerk and Stripe respectively. They reject unsigned requests with 401. Do not call them from your own code — you'll never need to.

Error codes

StatusCodeTypical cause
400INVALID_URLSBad URL shape, unsupported protocol, too many URLs.
400BAD_REQUESTMalformed body, bad policy, invalid pagination.
400INVALID_JSONRequest body was not parsable JSON.
401AUTH_REQUIREDRoute requires a signed-in Linky session.
403FORBIDDENNot the owner, wrong role (e.g. viewer trying to PATCH, editor trying to DELETE), or missing scope on the API key. The error message names the missing dimension.
404NOT_FOUNDUnknown slug, or Linky was soft-deleted.
429RATE_LIMITEDEither the anonymous create IP rate limit, or a bearer key's per-hour rateLimitPerHour bucket was exhausted. The response body includes retryAfterSeconds; SDK callers read LinkyApiError.retryAfterSeconds directly. See Limits.
500INTERNAL_ERRORServer / database issue; safe to retry.