LinkFlow API

Create short links, generate trackable dynamic QR codes, and pull click analytics — all over a plain JSON REST API. Every request and response body uses camelCase field names. This page is the full reference; interactive Swagger docs (auto-generated from the same schema) live at /docs.

Base URL

https://api.lnkfl.sbs

Running LinkFlow yourself instead? Point these examples at your own host — locally that's typically http://localhost:8000.

Authentication

Every authenticated endpoint accepts a bearer token in the Authorization header — either kind of token, sent the exact same way:

Authorization: Bearer <token>
Token typeLooks likeUse it for
JWT access token eyJhbGciOi... Browser/dashboard sessions. Short-lived (~30 min); refresh with POST /refresh.
API key lfk_3f9a1c2b7e4d... Server-to-server integrations, scripts, CI. Long-lived until you revoke it.
Both authenticate to the same workspace with the same permissions — an API key is just a non-expiring stand-in for your JWT, meant for code that can't run an interactive login. There's no separate scoping between them yet.

Get a JWT (register or log in)

# Register
curl -X POST https://api.lnkfl.sbs/register \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "a-strong-password"}'

# Log in (existing account)
curl -X POST https://api.lnkfl.sbs/login \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "a-strong-password"}'
const res = await fetch("https://api.lnkfl.sbs/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "you@example.com", password: "a-strong-password" }),
});
const { accessToken, refreshToken } = await res.json();
import requests

resp = requests.post(
    "https://api.lnkfl.sbs/login",
    json={"email": "you@example.com", "password": "a-strong-password"},
)
tokens = resp.json()
access_token = tokens["accessToken"]

Both return a TokenPair: { accessToken, refreshToken }.

Creating and managing API keys

API keys are created and revoked while logged in with a JWT — a key can't create another key.

  1. Log in and grab your accessToken (above).
  2. Create a key with POST /api-keys, giving it a name so you can tell keys apart later.
  3. Copy the key field immediately — it's returned once, at creation, and never again. LinkFlow only stores its hash.
  4. Use it as a bearer token on any endpoint below in place of a JWT.
POST/api-keysJWT only
FieldType
namestringrequired · 1–255 chars
curl -X POST https://api.lnkfl.sbs/api-keys \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "CI pipeline"}'
const res = await fetch("https://api.lnkfl.sbs/api-keys", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "CI pipeline" }),
});
const key = await res.json();
console.log(key.key); // shown once — store it now
resp = requests.post(
    "https://api.lnkfl.sbs/api-keys",
    headers={"Authorization": f"Bearer {access_token}"},
    json={"name": "CI pipeline"},
)
key = resp.json()["key"]  # shown once — store it now
Response (201):
{
  "id": "b3c1...",
  "name": "CI pipeline",
  "prefix": "lfk_3f9a1c2b7e4d",
  "key": "lfk_3f9a1c2b7e4dQ7z...           ← full secret, shown once
  "lastUsedAt": null,
  "createdAt": "2026-08-28T09:00:00Z",
  "revokedAt": null
}
GET/api-keysJWT only

Lists your keys, paginated. Each item shows the prefix (e.g. lfk_3f9a1c2b7e4d) for identification, never the secret.

DELETE/api-keys/{id}JWT only

Revokes a key immediately (204). Revoked keys stop authenticating on their very next request.

Treat an API key like a password — anyone who has it can act as your workspace. Revoke and reissue a key the moment you suspect it's leaked.

Rate limits

Limits are per client IP, in a sliding window. Exceeding one returns 429 with a Retry-After header.

ScopeLimit
Auth (/login, /register, /refresh)30 / min
/forgot-password5 / 15 min
GET /links/preview10 / min
POST /links/import3 / min
GET /links/export5 / min
GET /qr/{id}/image20 / min
Analytics reads20 / min
Public /tools/* (shared bucket, weighted)10 / min
Short-link redirects (GET /{slug})30 / min

Errors

Every error is one JSON envelope, regardless of endpoint:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Link not found.",
    "details": {}
  }
}
StatusCode
401UNAUTHORIZED — missing/invalid/revoked token or key
403FORBIDDEN
404NOT_FOUND
409CONFLICT — e.g. slug already taken
410GONE — link inactive with no fallback URL
422VALIDATION_ERROR
429RATE_LIMITED

QR codes

POST/qrJWT or API key

Create (or replace) the QR code for a link. Calling it again for the same linkId updates the existing code instead of creating a duplicate.

FieldType
linkIduuidrequired
foregroundColorstringoptional · hex, default #0f172a
backgroundColorstringoptional · hex, default #ffffff
logoUrlstringoptional · composited into PNG output only
curl -X POST https://api.lnkfl.sbs/qr \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"linkId": "b3c1...", "foregroundColor": "#0f172a"}'
const res = await fetch("https://api.lnkfl.sbs/qr", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ linkId: "b3c1..." }),
});
resp = requests.post(
    "https://api.lnkfl.sbs/qr",
    headers={"Authorization": f"Bearer {api_key}"},
    json={"linkId": "b3c1..."},
)
GET/qrJWT or API key

Paginated list of your QR codes.

GET/qr/{id}JWT or API key

Fetch one QR code's metadata (colors, scanCount, etc.) — not the image itself.

GET/qr/{id}/image?format=png|svgJWT or API key

Downloads the rendered image. Scans of this image are tracked as scanCount, separate from the link's clickCount.

curl -o qr.png -H "Authorization: Bearer $API_KEY" \
  "https://api.lnkfl.sbs/qr/b3c1.../image?format=png"
const res = await fetch(`https://api.lnkfl.sbs/qr/${id}/image?format=png`, {
  headers: { "Authorization": `Bearer ${apiKey}` },
});
const blob = await res.blob();
resp = requests.get(
    f"https://api.lnkfl.sbs/qr/{id}/image",
    params={"format": "png"},
    headers={"Authorization": f"Bearer {api_key}"},
)
with open("qr.png", "wb") as f:
    f.write(resp.content)

Campaigns

Group links under a campaign for reporting. POST /campaigns, GET /campaigns, GET /campaigns/{id}, DELETE /campaigns/{id} (archives — links keep their campaignId, no cascade). All JWT or API key.

Analytics

GET /analytics, GET /links/{id}/analytics, and GET /campaigns/{id}/analytics return click aggregates (time series, referrers, devices). All JWT or API key, capped at 20 requests/min. Raw client IPs are never returned or stored — only an HMAC hash.

Public tools

No authentication required. Every response sets Cache-Control: no-store. Shared rate-limit bucket across all of them.

EndpointBodyPurpose
POST /tools/url/shorten{ url }Anonymous short link, 90-day expiry
POST /tools/url/expand{ url }Resolve a short URL back to its destination
POST /tools/url/redirect-chain{ url }Full hop-by-hop redirect trace
POST /tools/url/bulk-status{ urls: [] } (≤10)Status check many URLs at once
POST /tools/url/broken-links{ url }Crawl a page's outbound links, report broken ones
POST /tools/url/response-headers{ url }Inspect a URL's response headers
POST /tools/qr/redirect-check{ url }Resolve where a QR-encoded URL leads
POST /tools/qr/classify{ url }Heuristic: does this look like a static or dynamic QR code?
curl -X POST https://api.lnkfl.sbs/tools/url/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/a/very/long/path"}'
const res = await fetch("https://api.lnkfl.sbs/tools/url/shorten", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ url: "https://example.com/a/very/long/path" }),
});
resp = requests.post(
    "https://api.lnkfl.sbs/tools/url/shorten",
    json={"url": "https://example.com/a/very/long/path"},
)