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 type | Looks like | Use 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. |
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.
- Log in and grab your
accessToken(above). - Create a key with
POST /api-keys, giving it a name so you can tell keys apart later. - Copy the
keyfield immediately — it's returned once, at creation, and never again. LinkFlow only stores its hash. - Use it as a bearer token on any endpoint below in place of a JWT.
/api-keysJWT only| Field | Type | |
|---|---|---|
name | string | required · 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 nowresp = 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{
"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
}
/api-keysJWT onlyLists your keys, paginated. Each item shows the prefix (e.g. lfk_3f9a1c2b7e4d) for identification, never the secret.
/api-keys/{id}JWT onlyRevokes a key immediately (204). Revoked keys stop authenticating on their very next request.
Rate limits
Limits are per client IP, in a sliding window. Exceeding one returns 429 with a Retry-After header.
| Scope | Limit |
|---|---|
Auth (/login, /register, /refresh) | 30 / min |
/forgot-password | 5 / 15 min |
GET /links/preview | 10 / min |
POST /links/import | 3 / min |
GET /links/export | 5 / min |
GET /qr/{id}/image | 20 / min |
| Analytics reads | 20 / 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": {}
}
}
| Status | Code |
|---|---|
401 | UNAUTHORIZED — missing/invalid/revoked token or key |
403 | FORBIDDEN |
404 | NOT_FOUND |
409 | CONFLICT — e.g. slug already taken |
410 | GONE — link inactive with no fallback URL |
422 | VALIDATION_ERROR |
429 | RATE_LIMITED |
Short links
/linksJWT or API keyCreate a short link.
| Field | Type | |
|---|---|---|
destinationUrl | string | required · 1–2048 chars |
slug | string | optional · 3–64 chars · auto-generated if omitted |
title | string | optional |
expiresAt | datetime | optional · must be in the future |
fallbackUrl | string | optional · used once the link is inactive |
campaignId | uuid | optional |
tags | string[] | optional |
curl -X POST https://api.lnkfl.sbs/links \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"destinationUrl": "https://example.com/pricing", "slug": "pricing"}'const res = await fetch("https://api.lnkfl.sbs/links", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ destinationUrl: "https://example.com/pricing", slug: "pricing" }),
});
const link = await res.json();resp = requests.post(
"https://api.lnkfl.sbs/links",
headers={"Authorization": f"Bearer {api_key}"},
json={"destinationUrl": "https://example.com/pricing", "slug": "pricing"},
)
link = resp.json(){
"id": "b3c1...", "slug": "pricing",
"shortUrl": "lnkfl.sbs/pricing",
"destinationUrl": "https://example.com/pricing",
"status": "active", "clickCount": 0,
"createdAt": "2026-08-28T09:00:00Z", "updatedAt": "2026-08-28T09:00:00Z",
"expiresAt": null, "fallbackUrl": null, "campaignId": null, "tags": []
}
/linksJWT or API keyPaginated list — ?page=, ?pageSize=.
/links/{id}JWT or API keyFetch one link.
/links/{id}JWT or API keyArchives the link (204). GET /{slug} then redirects to fallbackUrl if set, or returns 410.
/links/previewJWT or API key?url= → fetches the destination and returns its title/description/image for a share-card preview.
/{slug}noneThe redirect itself — a 302 to the destination URL. This is what you hand out publicly, not a call your integration makes.
QR codes
/qrJWT or API keyCreate (or replace) the QR code for a link. Calling it again for the same linkId updates the existing code instead of creating a duplicate.
| Field | Type | |
|---|---|---|
linkId | uuid | required |
foregroundColor | string | optional · hex, default #0f172a |
backgroundColor | string | optional · hex, default #ffffff |
logoUrl | string | optional · 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..."},
)/qrJWT or API keyPaginated list of your QR codes.
/qr/{id}JWT or API keyFetch one QR code's metadata (colors, scanCount, etc.) — not the image itself.
/qr/{id}/image?format=png|svgJWT or API keyDownloads 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.
| Endpoint | Body | Purpose |
|---|---|---|
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"},
)