Download OpenAPI specification:Download
Powerful link management API for creating, managing, and tracking shortened links.
All API requests require authentication using an API key. Include your API key in the Authorization header:
Authorization: Bearer your_api_key_here
Everything that has changed in this API, newest first. Check here before you ship — new capabilities show up in this list before you would notice them anywhere else.
The version field of this document (currently 1.8.0) moves with the API
and follows semantic versioning:
| Bump | Means |
|---|---|
| MAJOR | A breaking change — a field or endpoint removed, a response shape changed, a previously optional field made required. |
| MINOR | A new endpoint, a new optional field, a new accepted value. Existing integrations keep working untouched. |
| PATCH | Documentation corrections and bug fixes with no contract change. |
Nothing is removed without warning. A field on its way out is first marked
deprecated in this reference and listed under Deprecated below, and it keeps
working for at least one MINOR cycle. Deprecated fields are still accepted, so
an integration that has not migrated yet will not start failing.
Two habits keep you safe against MINOR releases: ignore response fields you
do not recognise rather than validating strictly against a fixed shape, and
never assume a list field is a merge — several (geo_rules, Shield's
rules) replace the whole array.
Added — Geo Filters (geo_rules)
Per-visitor overrides on a link: block a country, redirect a region to a
localized destination, or serve a different landing page per browser language.
Settable on PUT /api/v1/links and PATCH /api/v1/links/{id}, returned in full
by GET /api/v1/links/{id}, and summarised as geo_rules_count on
GET /api/v1/links. See the Geo Filters section for the model and examples.
Added — Shield (/api/v1/links/{link_id}/shield)
Traffic filtering and cloaking as a first-class resource: read, replace (PUT),
patch, and disable (DELETE) a link's Shield configuration, plus
GET /api/v1/shield for the condition vocabulary, GET /api/v1/shield/presets
for the built-in presets, and GET /api/v1/shield/bots for the crawler
registry. See the Shield section.
Deprecated — geolocation_enabled, geolocation_redirects
These two fields on PATCH /api/v1/links/{id} were never read by the serve
layer: setting them did nothing, and links "configured" with them were not
geo-targeted at all. They are still accepted so existing callers do not break,
but they are now discarded rather than stored. Migrate to geo_rules — a
{ countries, url } entry becomes a rule with t: "d_l".
Fixed — link detail response documented correctly
GET /api/v1/links/{id} returns { link, project }. It was previously
documented as { success, data }, which never matched the actual response. The
endpoint itself is unchanged; only the reference was wrong.
Added — Landing Pages (v2)
Read and write Page Builder v2 pages on links and templates
(/api/v1/links/{link_id}/landing, /api/v1/templates/{template_id}/landing),
with version history, per-link dynamic-overrides, and the
/api/v1/landing-engine contract and example endpoints.
Added — Visit logs
GET /api/v1/logs, /api/v1/links/{link_id}/logs and
/api/v1/folders/{folder_id}/logs — cursor-paginated visit history with masked
IPs and each visit's clicks merged in. See the API Logs section.
Added — Social networks & trending links
Connected-account analytics, post metrics and history under
/api/v1/social-networks, plus GET /api/v1/trending-links.
Added — Folders
/api/v1/folders and folder-scoped statistics.
Initial public API — links, templates, assets and statistics.
Keeping this list current (internal note). This changelog lives in the
info.descriptionofstatic/openapi.yamland nowhere else — there is no second copy to drift out of sync. When you change the API: add a dated entry at the top of the list under the right label (Added / Changed / Deprecated / Removed / Fixed), say what a consumer must do rather than what the code now does, and bumpinfo.versionplus the number quoted under Versioning above.
LinkScale uses a secure, three-step upload process with Uploadcare CDN for optimal performance and security.
Step 1: Get Upload Signature
PUT /api/v1/assets
Body: { "expiration_minutes": 10 }
→ Returns: upload_config with signature
Step 2: Upload to Uploadcare
POST upload_config.upload_url
FormData with: file, signature, public_key, metadata
→ Returns: { "file": "uuid-file-id" }
Step 3: Poll for Validation
GET /api/v1/assets/{file_id}
Poll every 2 seconds until 200 OK (usually 2-3 seconds)
→ Returns: Complete asset with CDN URL
See the detailed documentation in the Assets endpoints for complete code examples in JavaScript/Node.js.
LinkScale provides powerful dynamic features that allow you to reuse templates and configurations while customizing specific elements per link.
Override specific template properties (name and profile picture) while maintaining the template's design. This is particularly useful when using the same template (cs_template) for multiple links but with different profile information.
Use Case: You have a company template with standard branding, but want to create personalized links for different team members with their own names and profile pictures.
Key Features:
cs_template is specifiedExample:
{
"cs_template": "507f1f77bcf86cd799439011",
"dynamic_informations": {
"enabled": true,
"pp_enabled": true,
"n": "John Doe",
"pp": {
"url": "https://cdn.example.com/john.jpg",
"enabled": true,
"size": 150,
"border": {
"color": "#4A90E2",
"style": "solid",
"width": 3
}
}
}
}
Dynamically manage and customize link arrays within your landing pages for flexible content management.
Geo Filters make a single link behave differently depending on who is opening it — block a country, send a region to a localized destination, or serve a different landing page per browser language. They are configured with the geo_rules array on a link, available on both PUT /api/v1/links (create) and PATCH /api/v1/links/{id} (update).
A geo rule is a partial link override. Every enabled rule is evaluated against the incoming visitor; the single highest-priority match is then merged onto the link, and the visitor is served that instead of the link's own destination. Visitors matching no rule get the link normally.
A rule answers two questions — who matches (detection_type + its criteria) and what they get (t + its payload).
{
"detection_type": "ip", // who: by IP geolocation, or "browser_language"
"countries": ["US", "CA"], // ...specifically these countries
"t": "d_l", // what: redirect ("block" / "d_l" / "l_p")
"url": "https://example.com/na" // ...to here
}
| Field | Applies to | Meaning |
|---|---|---|
enabled |
all | Defaults to true. A rule that is not enabled is skipped before anything else is read. |
detection_type |
all | ip (default) or browser_language. |
location |
ip |
One ISO-3166-1 alpha-2 code, or a group key that expands to many countries. |
countries |
ip |
ISO-3166-1 alpha-2 codes; matches any of them. |
regions / cities |
ip |
Narrow the match inside the matched country. Raises the rule's priority. |
language |
browser_language |
Matched as a substring, so "fr" also catches fr-CA. |
t |
all | block → 404, d_l → redirect to url, l_p → serve a landing page. |
url |
t: d_l |
Where matched visitors are sent. Required for d_l. |
cs_template |
t: l_p |
Project template ObjectId, resolved at serve time. |
landing_v2_page |
t: l_p |
Inline Page Builder v2 page. Takes precedence over cs_template. |
Group keys accepted by location: AFRICA, MIDDLE_EAST, EUROPE, ASIA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, LOW_GDP_PER_CAPITA.
All enabled rules are evaluated, then exactly one is applied — the most specific:
| Priority | Rule shape |
|---|---|
| 3 (highest) | ip plus regions and/or cities |
| 2 | browser_language |
| 1 (lowest) | ip alone |
Ties are broken by array order: the earlier rule wins. So a city-level rule always beats a country-level rule on the same link, no matter how you order them — and if you want two country rules evaluated in a particular order, put the more important one first.
geo_rules replaces the whole list. It is not a merge and there is no per-rule endpoint. To add a rule, read the current ones from GET /api/v1/links/{id}, append, and send the full array back. To remove them all, send [].GET /api/v1/links returns geo_rules_count, not the rules. A single rule can embed a whole landing page, so the list endpoint returns only a count plus geo_rules_updated_at. Fetch the link by id for the rules themselves.ip rule with no country, a browser_language rule with no language, a d_l rule with no url, or an l_p rule with nothing to serve all return 400 naming the missing field.geolocation_enabled and geolocation_redirects are deprecated no-ops. They were never read by anything — links "configured" with them were not geo-targeted at all. They are still accepted so old callers don't break, but they are discarded. Use geo_rules.Block France, route North America to a regional page, and give French speakers elsewhere a localized destination:
curl -X PATCH https://app.linkdm.me/api/v1/links/<LINK_ID> \
-H "Authorization: Bearer lk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"geo_rules": [
{ "detection_type": "ip", "location": "FR", "t": "block" },
{ "detection_type": "ip", "countries": ["US", "CA"], "t": "d_l", "url": "https://example.com/north-america" },
{ "detection_type": "browser_language", "language": "fr", "t": "d_l", "url": "https://example.com/fr" }
]
}'
A visitor in Paris gets a 404. A visitor in Toronto is redirected to /north-america — even with a French browser, because the IP rule and the language rule both match and ties are broken by order. A French-speaking visitor in Belgium gets /fr. Everyone else gets the link's own destination.
const headers = { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' };
// 1. Read the rules that already exist — PATCH would otherwise wipe them.
// The detail endpoint responds with { link, project }.
const res = await fetch(`https://app.linkdm.me/api/v1/links/${linkId}`, { headers });
const { link } = await res.json();
const existing = link.geo_rules ?? [];
// 2. Send the full list back with the new rule appended.
await fetch(`https://app.linkdm.me/api/v1/links/${linkId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({
geo_rules: [...existing, { detection_type: 'ip', location: 'DE', t: 'd_l', url: 'https://example.com/de' }]
})
});
How the /api/v1/.../logs endpoints work, why they scale, and the caveats you should know before promising things to API consumers.
A LinkDM user creates an API key in their dashboard and ships it with every request. The key authenticates the call, scopes it to their project, and grants per-resource permissions. The endpoint returns visit logs from ClickHouse, paginated 100 at a time, newest-first, with raw IPs masked (12.**.**.78) and each visit's recent button clicks already merged in.
curl https://app.linkdm.me/api/v1/links/<LINK_ID>/logs?limit=100 \
-H "Authorization: Bearer lk_xxxxxxxxxxxx"
{
"success": true,
"data": [
{
"_id": "65f1a2…",
"timestamp": "2026-04-28T11:42:13.512Z",
"country": "FR",
"city": "Paris",
"ip": "82.**.**.117",
"userAgent": "Mozilla/5.0 …",
"device_type": "mobile",
"bot": 0,
"host": "linkdm.me",
"referer": "https://t.co/…",
"clicks": [
{ "url": "https://example.com", "btn_id": "btn_a", "created_at": "…", "is_final": 1 }
]
}
],
"next_cursor": "2026-04-28T11:42:13.512Z",
"has_more": true
}
To walk every page, loop with the next_cursor until has_more === false:
let cursor = null;
do {
const url = `https://app.linkdm.me/api/v1/links/${linkId}/logs?limit=100${cursor ? `&last_timestamp=${encodeURIComponent(cursor)}` : ''}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
const { data, next_cursor, has_more } = await res.json();
for (const visit of data) { /* process */ }
cursor = next_cursor;
} while (cursor);
| Limit | Value | Why |
|---|---|---|
| Max history window | 30 days | ClickHouse perf guardrail; older data not retrievable through this endpoint |
| Page size | 100 max (default 100) | Bounds memory + response size |
| Rate limit | 2 req/s per API key | Backpressure on ClickHouse |
from/to window |
31 days max | Joi-enforced, returns 400 if exceeded |
| Clicks per visit | 50 max in clicks[] |
One hot visit can't blow up the response |
| IP format | always masked | Raw IPs never leave the server (IPv4: a.**.**.d, IPv6: aaaa:bbbb:****:…:zzzz) |
| Code | When |
|---|---|
200 |
Success |
400 |
Validation error (bad cursor, bad limit, bad date range) |
401 |
Missing / malformed / unknown / inactive API key |
403 |
API key lacks logs.read_link / logs.read_folder / logs.read_project permission |
404 |
link_id / folder_id doesn't belong to the caller's project |
429 |
Rate limit (2 rps) exceeded; Retry-After: 1 |
500 |
Unexpected server error (ClickHouse down, etc.) |
Authorization: Bearer lk_xxxxx.projects_api_keys (Mongo). Raw keys are never stored.permissions object (e.g. { logs: { read_link: true } }) is loaded onto the request.logs.read_project for /logs, logs.read_link for /links/:id/logs, etc.). Scope-strict — read_project does not grant read_link.project_id = <caller's project> at the SQL level. A consumer cannot read another tenant's data even by guessing IDs.projects_api_logs for audit.| Endpoint | Scope |
|---|---|
GET /api/v1/logs |
All visits across the project |
GET /api/v1/links/{link_id}/logs |
One link's visits |
GET /api/v1/folders/{folder_id}/logs |
All visits for links in one folder |
All three share one controller (src/controllers/public-api/logs/getLogsController.js) and one ClickHouse helper (src/lib/helpers/stats/clickhouseLogs.js).
Authorization: Bearer lk_xxxxx (validated against api_keys in MongoDB).logs.read_project, logs.read_folder, logs.read_link.handleApiKeyRoute.js).projects_api_logs for audit.At the rate limit, the practical ceiling is 200 visits/second per key — fine for almost any sane export job.
| Param | Default | Notes |
|---|---|---|
limit |
100 |
Min 1, max 100. |
last_timestamp |
— | Cursor for the next page (see below). |
from, to |
— | ISO datetimes. Optional, but capped by the date-range limit middleware. |
source |
visits |
visits (one row per visit, with embedded clicks[]) or clicks (one row per click event). |
country |
— | ISO country code. Only honored on source=visits. |
visitor_type |
all |
humans / bots / all. Only honored on source=visits. |
{
"success": true,
"data": [ /* up to `limit` rows, newest first */ ],
"next_cursor": "2026-04-28T11:42:13.512Z",
"has_more": true
}
next_cursor = the timestamp of the last row, or null when the page is partial.has_more = true while a full page is returned. May produce one false-positive empty page on the boundary (no data is lost).source=visits)Top-level visit fields:
_id, timestamp, country, city, u, referer, bot, host, project_id, id (link_id), userAgent, device_type, ip (masked), prx, vpn, vpn_org, vpn_provider, blocked, spam, url_params, clicks[].
clicks[] carries up to 50 most recent clicks per visit, each with: url, btn_id, position, btn_v, action_type, is_final, created_at.
12.34.56.78 → 12.**.**.782a01:cb00:1234:5678:9abc:def0:1234:5678 → 2a01:cb00:****:****:****:****:****:5678userAgent / referer strings are also masked by regex replacement.GET /api/v1/links/<id>/logs?limit=100
Authorization: Bearer lk_xxx
Save next_cursor from the response, then:
GET /api/v1/links/<id>/logs?limit=100&last_timestamp=<next_cursor>
Stop when has_more === false (or data is empty).
The cursor is just the timestamp of the last row, opaque to the client. The server filters with WHERE timestamp < parseDateTime64BestEffort(<cursor>) and orders DESC — so each page is the next 100 rows older than the last one returned.
stats table:
(project_id, timestamp, link_id, user_id)timestamplink_id and mongo_idclicks_stats table:
(project_id, created_at, link_id, mongo_id)stats_id and link_idThe cursor query
SELECT … FROM stats
WHERE timestamp >= now() - INTERVAL 30 DAY
AND project_id = ?
AND link_id = ? -- when scoped to a link
AND timestamp < <cursor> -- when paginating
ORDER BY timestamp DESC
LIMIT 100
hits the sort-key prefix (project_id, timestamp, …), so ClickHouse only reads the relevant granules from one or two monthly partitions — not the table.
The follow-up clicks query
SELECT … FROM clicks_stats WHERE stats_id IN (<≤100 ids>)
uses the bloom-filter index on stats_id to skip granules that don't contain any of those IDs. One batched query for all 100 visits, never N+1. A ROW_NUMBER() OVER (PARTITION BY stats_id ORDER BY created_at DESC) window caps the result at 50 clicks per visit so a single hot visit can't blow up the response.
For a single page (limit=100):
stats, returning ≤100 rows.clicks_stats with a bloom-filtered stats_id IN (…), returning ≤5,000 rows (100 × 50).ClickHouse is sized for this. The 30-day fence + sort-key prefix is what keeps the first query bounded.
Bearer lk_xxxxx. Header format is regex-validated (/^Bearer\s+lk_[A-Za-z0-9]+$/) and length-capped at 256 chars before any DB lookup, so malformed input never reaches Mongo (handleApiKeyRoute.js).sha256(api_key) in projects_api_keys (apiKeyAuth.js). Plaintext keys never logged, even in dev mode.is_active: true, plus successful $lookup joins to both users and projects (preserveNullAndEmptyArrays: false). A deleted user or project = 401.req.api_key_permissions at auth time; the controller then checks logs.read_project / read_folder / read_link per scope. Permissions are scope-strict — read_project does not grant read_link.project_id = req.project.project_id is hardcoded into every WHERE clause. The link-scope endpoint additionally verifies the link belongs to the project via Mongo (links.findOne({ _id, project_id })) before issuing the ClickHouse query — a user can't read another tenant's link by guessing the ObjectId.esc() before substitution. esc() escapes both backslash and single-quote (via backslash, which ClickHouse accepts inside single-quoted literals). A trailing backslash in country or any other field cannot break out of the string literal.esc():country — string().max(10)last_timestamp — string().isoDate().max(64) (so the cursor can't be a 1MB string and can't be malformed datetime → returns 400, not 500)limit — integer().min(1).max(100)source / visitor_type — strict enumfrom / to — ISO 8601, plus a 31-day window cap from withDateRangeLimitlink_id and folder_id are validated with ObjectId.isValid before they touch SQL.projects_api_logs) use the validated query object, so a malicious last_timestamp can't bloat Mongo storage.ROW_NUMBER window) prevents one hot visit from blowing up the response.maskIp for direct-IP fields, maskIpAddresses for IPs embedded in userAgent/referer).12.**.**.78), IPv6 (2a01:cb00:****:…:5678), and click-level ip fields when present.source=visits (cursor column timestamp) and source=clicks (cursor column created_at, aliased back to timestamp in the response).source=clicks because those columns don't exist on clicks_stats (instead of erroring).The ClickHouse query unconditionally adds timestamp >= now() - INTERVAL 30 DAY (in clickhouseLogs.js). Visits older than 30 days cannot be retrieved through this endpoint, even with explicit from/to parameters. This is a perf guardrail — removing it would let one bad query scan the whole table. If a longer window is needed, the right move is a separate "export job" path that runs async.
country / visitor_type only work on source=visitsThose columns don't exist on clicks_stats. The controller now silently ignores those filters when source=clicks rather than 500'ing — but the consumer needs to know. If you need country-filtered clicks, fetch with source=visits and reduce client-side.
Cursor is timestamp only. timestamp is DateTime64(3) (millisecond precision). If two visits land in the exact same millisecond on the cursor boundary, one could be skipped by the next page. In real traffic this is essentially never observed, but it's not zero. If it ever matters, the fix is a (timestamp, mongo_id) tuple cursor — non-trivial change, not worth doing pre-emptively.
has_more=true boundary false-positiveIf the very last page contains exactly limit rows, has_more will be true and the next call returns data: [] with has_more: false. No data is lost — clients just get one extra empty round-trip on the exact-multiple boundary.
A visit with >50 clicks will only show the 50 most recent in clicks[]. The total click count isn't separately surfaced; if a consumer needs the raw count, they have to use source=clicks and count.
The limiter is find over projects_api_logs with created_at >= now-1s. If MongoDB is unavailable, it fails open — the request proceeds. Acceptable because ClickHouse-side bounds protect the database, but worth knowing.
Permissions are loaded once at auth and used for the lifetime of the request. If a permission is revoked between auth and the controller running, that single in-flight call still proceeds with the old permission. The next call sees the new permissions. Standard behavior.
folder_id is validated as a syntactically valid ObjectId, but we don't check that the folder belongs to the project — instead we filter the links query by project_id. A folder from another project simply returns zero links → data: []. No data leak, but a caller can't distinguish "folder doesn't exist" from "folder is empty" or "folder belongs to another tenant". Acceptable trade-off, but document it for API consumers if needed.
scripts/clickhouse/diagnostics/diagnose_clicks_stats_schema.js — confirms sort key + indexes are still in place.system.query_log for the slow query: bloom-filter granule pruning ratio should be high. If not, the bloom index has degraded — OPTIMIZE TABLE clicks_stats FINAL can help (heavy operation, schedule it).grep "INTERVAL 30 DAY" src/lib/helpers/stats/clickhouseLogs.js). Removing it is the most common cause of "why did logs get slow".| # | Severity | Defect | Fix |
|---|---|---|---|
| 1 | Bug | ?source=clicks silently ignored last_timestamp — pagination broken on the clicks branch |
Cursor now applies to both sources, using created_at as the column for clicks |
| 2 | Bug | ?country= and ?visitor_type= filters were injected into the clicks_stats query, which has no such columns → 500 on those param combos |
Filters scoped to source=visits only; silently ignored for clicks |
| 3 | Soft DoS | esc() only escaped single quotes; a trailing \ could break out of the string literal and crash the SQL parser as a 500 |
esc() now escapes backslash first, then single quote (both via backslash, ClickHouse-accepted) |
| 4 | Hardening | last_timestamp was Joi.string().optional() — accepted any length, any content, only failed at ClickHouse parse time |
Now Joi.string().isoDate().max(64).optional() — rejected as 400 with a clear message |
| 5 | Spec compliance | Default limit was 30, next_cursor / has_more not in response, IP was deleted instead of masked |
Default 100; envelope now includes next_cursor + has_more; IP masked as 12.**.**.78 |
Shield decides who sees the real page. Every visit is evaluated against an ordered list of rules — WHEN (a condition tree) → THEN (block / allow / redirect) — and the first enabled rule that matches wins. Traffic matching no rule is served the real page.
# Protect a link with a one-click profile: bots, proxies, VPNs and datacenter
# IPs see the link's own landing page instead of the real destination.
curl -X PUT https://dashboard.linkscale.to/api/v1/links/<LINK_ID>/shield \
-H "Authorization: Bearer lk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "preset": "instagram"}'
That single call writes five real rules, sets the block screen, and disables every deeplink on the blocked traffic. GET the same URL to read it back — as rules, and as the simple buckets those rules map to.
| You want to... | Use |
|---|---|
| Ship a standard protection profile | preset — instagram, bots_only, hard_404 |
| Flip one traffic category | buckets — bot_known, bot_unknown, net_proxy, net_vpn, net_datacenter, bot:<botId> |
| Express anything else | rules — the full condition tree |
All three write into the same rules array, so a preset or a bucket is never a hidden setting: read the config back and you see the rules it produced.
The block screen is what makes Shield a cloaking tool rather than a firewall:
not_found — a plain 404.landing — a decoy landing page: one of your templates (resolved live, so editing the template updates what scanners see) or another link's live landing. {"source": "self"} serves the link's own landing.three_dots — the "open in browser" overlay.do_nothing — let it through (link default only).Each rule may carry its own screen; rules that do not inherit the link default. A decoy with no page attached is rejected with 400 instead of silently degrading to a 404 — the mistake that would quietly break a cloaking setup.
| id | What it does | Block screen |
|---|---|---|
instagram |
Bots (including link scanners like facebookexternalhit), proxies, VPNs and datacenter IPs see a decoy. Also cuts every deeplink on that traffic, since a scanner following one would expose the real destination. |
Decoy landing (the link's own, by default) |
bots_only |
Every bot gets the 3-dots overlay; real human proxy / VPN traffic is untouched. | 3-dots overlay |
hard_404 |
Bots, proxies, VPNs and datacenter IPs get a plain 404. | 404 |
GET /api/v1/shield returns the machine-readable vocabulary — every condition type and the values it accepts, the actions, the block screens, the buckets, the presets and the registered-bot registry — generated from the same catalogues the dashboard renders. GET /api/v1/shield/presets and GET /api/v1/shield/bots return the two catalogues on their own.
Links predating the rule engine return model: "legacy" with rules populated by a deterministic compilation of their old tri-state fields (that is what the dashboard opens, and what the serve layer falls back to). Writing any rule, bucket or preset migrates the link to model: "v2".
Looking for practical examples and ready-to-use scripts? Visit our GitHub organization for concrete implementations:
🔗 LinkScale GitHub - Code Examples
You'll find:
These repositories provide production-ready code you can use as a foundation for your own implementations.
Create a new shortened link with optional customization options
| type required | string Enum: "l_p" "d_l" Link type - 'l_p' for landing page or 'd_l' for direct link. IMPORTANT: This field is required and determines the link behavior:
|
| u required | string Username or unique identifier (can be empty string) |
| domain required | string Domain for the link |
| url | string <uri> Target URL to redirect to. REQUIRED when type is 'd_l' (direct link). Optional when type is 'l_p' (landing page). |
| n | string Display name (optional, can be empty) |
| bio | string Bio or description text (optional, can be empty) |
Array of objects Array of link objects for the landing page | |
object | |
object | |
object | |
object | |
object | |
object | |
| template | string Template identifier |
| cs_template | string Contact sheet template ID (ObjectId format). Optional for landing pages (type 'l_p'). This field is used to customize the appearance of landing pages. |
object | |
object | |
object Social media settings | |
object | |
object | |
Array of objects (GeoRule) Geo Filter — per-visitor overrides evaluated before the link is served. Block a country, redirect a region to a localized destination, or serve a different landing page per language. Only the single highest-priority match is applied, and a matched rule suppresses A/B test flows for that visitor. See the Geo Filters section of the introduction for the full model and worked examples. | |
| shield | boolean Default: true Master Shield switch. Links created through the API have Shield on by default; Shield never runs while this is false. |
| folders | Array of strings Array of folder IDs (ObjectId format) |
| enabled | boolean Whether the link is active |
| note | string Internal note (can be empty) |
object Dynamic informations allow overriding specific template properties (name and profile picture) while maintaining the template's design. Only works when cs_template is specified. | |
| shield_preset | string Enum: "instagram" "bots_only" "hard_404" Applied on create. Naming a preset here builds the whole protection
profile in the same call — its rules and its default block screen — so a
single |
Array of objects (ShieldRule) Shield V2 — the ordered rule list for the new link. Same validation and decoy resolution as the dedicated Shield resource. | |
object Shield V2 — the default block screen for the new link. A decoy landing with nothing to render is rejected with 400 rather than silently serving a 404. |
{- "type": "l_p",
- "u": "johndoe",
- "domain": "link.dm",
- "n": "John Doe",
- "bio": "Software Developer"
}{- "success": true,
- "data": {
- "id": "abc123def456",
- "type": "l_p",
- "created_at": "2024-01-15T10:30:00Z"
}
}Retrieve a paginated list of your links with optional filtering
| page | integer >= 1 Default: 1 Page number for pagination |
| limit | integer [ 1 .. 100 ] Default: 20 Number of links per page |
| tag | string Filter by tag |
| search | string Search in title and description |
{- "success": true,
- "data": [
- {
- "id": "abc123def456",
- "title": "Example Website",
- "description": "A demonstration website for testing",
- "tags": [
- "demo",
- "testing"
], - "clicks": 42,
- "dynamic_informations": {
- "enabled": true,
- "pp_enabled": true,
- "n": "Custom Name",
- "pp": { }
}, - "geo_rules": [
- {
- "id": "eu-visitors",
- "enabled": true,
- "detection_type": "ip",
- "t": "block",
- "location": "FR",
- "countries": [
- "US",
- "CA",
- "MX"
], - "regions": [
- "Ile-de-France"
], - "cities": [
- "Paris",
- "Marseille"
], - "language": "fr",
- "cs_template": "507f1f77bcf86cd799439011",
- "landing_v2_page": {
- "sections": [
- { }
]
}
}
], - "geo_rules_count": 2,
- "geo_rules_updated_at": "2024-01-15T10:30:00Z",
- "shield": true,
- "shield_preset": "instagram",
- "shield_rules_count": 5,
- "shield_block_behavior": "landing",
- "shield_model": "v2",
- "created_at": "2024-01-15T10:30:00Z",
- "updated_at": "2024-01-15T10:30:00Z",
- "expires_at": "2024-12-31T23:59:59Z",
- "is_active": true
}
], - "pagination": {
- "page": 1,
- "limit": 20,
- "total": 150,
- "pages": 8
}
}Retrieve detailed information about a specific link
| id required | string The unique identifier of the link |
{- "link": {
- "id": "abc123def456",
- "title": "Example Website",
- "description": "A demonstration website for testing",
- "tags": [
- "demo",
- "testing"
], - "clicks": 42,
- "dynamic_informations": {
- "enabled": true,
- "pp_enabled": true,
- "n": "Custom Name",
- "pp": { }
}, - "geo_rules": [
- {
- "id": "eu-visitors",
- "enabled": true,
- "detection_type": "ip",
- "t": "block",
- "location": "FR",
- "countries": [
- "US",
- "CA",
- "MX"
], - "regions": [
- "Ile-de-France"
], - "cities": [
- "Paris",
- "Marseille"
], - "language": "fr",
- "cs_template": "507f1f77bcf86cd799439011",
- "landing_v2_page": {
- "sections": [
- { }
]
}
}
], - "geo_rules_count": 2,
- "geo_rules_updated_at": "2024-01-15T10:30:00Z",
- "shield": true,
- "shield_preset": "instagram",
- "shield_rules_count": 5,
- "shield_block_behavior": "landing",
- "shield_model": "v2",
- "created_at": "2024-01-15T10:30:00Z",
- "updated_at": "2024-01-15T10:30:00Z",
- "expires_at": "2024-12-31T23:59:59Z",
- "is_active": true
}, - "shield": {
- "enabled": true,
- "preset": "instagram",
- "model": "v2",
- "mode": "simple",
- "rules": [
- {
- "id": "simple_bot_known",
- "enabled": true,
- "label": "Link scanners",
- "match": {
- "op": "any",
- "rules": [
- {
- "type": "BOT",
- "match": "is",
- "values": [
- "known"
]
}, - {
- "type": "NETWORK",
- "match": "is",
- "values": [
- "vpn",
- "datacenter"
]
}
]
}, - "action": {
- "type": "block",
- "block": {
- "behavior": "landing",
- "landing": {
- "source": "template",
- "template_id": "692d91eec003c7d6b3ab6273",
- "template_name": "Clean bio page",
- "template_generated": true,
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "link_u": "john",
- "link_domain": "lnkdm.me",
- "link_name": "John's page",
- "has_page_snapshot": true,
- "page": { }
}
}, - "deeplinks_logic": {
- "instagram_landing": {
- "type": "open_in_webview"
}, - "direct_browser_open": {
- "type": "without_deeplink"
}
}
}, - "summary": "registered bots or VPN",
- "warning": {
- "kind": "empty",
- "message": "No condition yet — this rule never matches."
}
}
], - "buckets": {
- "bot_known": {
- "action": "block"
}, - "net_vpn": {
- "action": "block"
}
}, - "block": {
- "behavior": "landing",
- "landing": {
- "source": "template",
- "template_id": "692d91eec003c7d6b3ab6273",
- "template_name": "Clean bio page",
- "template_generated": true,
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "link_u": "john",
- "link_domain": "lnkdm.me",
- "link_name": "John's page",
- "has_page_snapshot": true,
- "page": { }
}
}, - "ready": true,
- "issues": [
- {
- "scope": "default",
- "message": "string"
}
], - "stats": {
- "rules_total": 5,
- "rules_enabled": 5,
- "custom_rules": 0,
- "blocks": 5,
- "allows": 0,
- "redirects": 0
}, - "legacy": {
- "vpn": "off",
- "proxy": "off",
- "bot_known": "off",
- "bot_unknown": "off",
- "bot_rules": {
- "property1": "string",
- "property2": "string"
}, - "block_vpn": true,
- "block_proxy": true
}
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Update an existing link (partial update). Only provided fields will be updated.
| id required | string The unique identifier of the link |
| url | string <uri> Target URL to redirect to (for direct links) |
| u | string Username or unique identifier |
| n | string Display name |
| bio | string Bio or description text |
Array of objects Array of link objects for the landing page | |
object | |
object | |
object | |
| template | string Template identifier |
Array of objects (GeoRule) Geo Filter — per-visitor overrides evaluated before the link is served. This field is replace-the-whole-list, not a merge: the array you send
becomes the link's complete rule set. To add a rule, send the existing
rules plus the new one (read them back from Each write stamps | |
| geolocation_enabled | boolean Deprecated Deprecated and ignored. This field was never read by the serve layer —
setting it did nothing. Use |
Array of objects Deprecated Deprecated and ignored. Never read by the serve layer — links
configured with it were not geo-targeted at all. Use | |
| shield | boolean Master Shield switch for this link. Shield never runs while this is false. |
| enabled | boolean Whether the link is active |
| note | string Internal note |
object Dynamic informations allow overriding specific template properties (name and profile picture) while maintaining the template's design. Only works when cs_template is specified. | |
Array of objects (ShieldRule) Shield V2 — the ordered rule list, replacing whatever is stored.
Validated exactly like the dedicated Shield resource: unknown condition
types or values are rejected with the path of the offending entry, decoy
references ( For presets, simple buckets and a readiness verdict, prefer
| |
object Shield V2 — the link's default block screen, inherited by every rule that does not carry its own. | |
| shield_preset | string Enum: "instagram" "bots_only" "hard_404" Records which Shield preset the configuration came from. On update this
is stored as-is (cosmetic) — to actually apply a preset, send
|
{
}{- "success": true,
- "message": "Link updated successfully",
- "data": {
- "id": "abc123def456",
- "type": "l_p",
- "updated_at": "2025-10-08T10:30:00.000Z"
}, - "timestamp": "2025-10-08T10:30:00.000Z"
}Permanently delete a link. This action cannot be undone.
| id required | string The unique identifier of the link |
{- "success": true,
- "message": "Link deleted successfully",
- "data": {
- "id": "abc123def456",
- "deleted_at": "2025-10-08T10:30:00.000Z"
}, - "timestamp": "2025-10-08T10:30:00.000Z"
}Create a new template for the authenticated project with customization options
| t_name | string Template name (optional) |
| type | string Enum: "l_p" "d_l" Link type - 'l_p' for landing page or 'd_l' for direct link |
| url | string <uri> Target URL to redirect to (required when type is 'd_l') |
| n | string Display name (optional, can be empty) |
| bio | string Bio or description text (optional, can be empty) |
Array of objects Array of link objects for the landing page | |
object | |
object | |
object | |
| template | string Template identifier |
object Social media settings | |
| shield | boolean Enable shield protection |
| enabled | boolean Whether the template is active |
| note | string Internal note (can be empty) |
{- "t_name": "My Custom Template",
- "type": "l_p",
- "n": "John Doe",
- "bio": "Professional developer and designer",
- "template": "default",
- "s": { },
- "shield": false,
- "enabled": true,
- "note": ""
}{- "success": true,
- "message": "Template created successfully",
- "data": {
- "template_id": "507f1f77bcf86cd799439011",
- "t_name": "My Custom Template",
- "created_at": "2024-01-15T10:30:00Z"
}
}Retrieve all templates for the authenticated project
{- "success": true,
- "data": [
- {
- "_id": "507f1f77bcf86cd799439011",
- "t_name": "My Custom Template",
- "type": "l_p",
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "enabled": true,
- "created_at": "2024-01-15T10:30:00Z"
}
]
}Retrieve a specific template by ID from the authenticated project
| template_id required | string Example: 507f1f77bcf86cd799439011 The unique identifier of the template |
{- "success": true,
- "data": {
- "_id": "507f1f77bcf86cd799439011",
- "t_name": "My Custom Template",
- "type": "l_p",
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "enabled": true,
- "n": "John Doe",
- "bio": "Professional developer",
- "pp": { },
- "cover": { },
- "background": { },
- "links": [ ],
- "created_at": "2024-01-15T10:30:00Z"
}
}Update a specific template from the authenticated project. Only provided fields will be updated.
| template_id required | string Example: 507f1f77bcf86cd799439011 The unique identifier of the template |
| t_name | string Template name |
| type | string Enum: "l_p" "d_l" Link type |
| url | string <uri> Target URL to redirect to |
| n | string Display name |
| bio | string Bio or description text |
Array of objects Array of link objects | |
object | |
object | |
object | |
| template | string Template identifier |
| shield | boolean Enable shield protection |
| enabled | boolean Whether the template is active |
| note | string Internal note |
{- "t_name": "New Template Name"
}{- "success": true,
- "message": "Template updated successfully",
- "data": {
- "template_id": "507f1f77bcf86cd799439011",
- "updated_at": "2024-01-15T10:30:00Z"
}
}Permanently delete a template from the authenticated project. This action cannot be undone.
| template_id required | string Example: 507f1f77bcf86cd799439011 The unique identifier of the template |
{- "success": true,
- "message": "Template deleted successfully",
- "data": {
- "template_id": "507f1f77bcf86cd799439011",
- "deleted_at": "2024-01-15T10:30:00Z"
}
}Return a link's Landing v2 configuration: the resolved page (draft or published), its lifecycle metadata, which customization model it uses, the Model B dynamic overrides (when present), and a live preview_url.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff The unique identifier of the link. |
| state | string Default: "published" Enum: "published" "draft" Which stored copy to return. |
| format | string Default: "json" Enum: "json" "html" Response format. json (default) returns the page JSON. html is not supported (returns 501) - to view the rendered page, open the link's live preview_url. |
{- "landing": {
- "version": 2,
- "state_returned": "published",
- "page": {
- "id": "page_ab12cd",
- "version": 2,
- "theme": {
- "font_family": "inter",
- "color_primary": "#2563eb",
- "color_text": "#0f172a",
- "color_bg": "#ffffff",
- "radius_scale": "soft"
}, - "meta": {
- "title": "Ana",
- "description": "Creator links",
- "og_image_uploadcare_uuid": "string",
- "favicon_uploadcare_uuid": "string"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "background": { },
- "style": { },
- "slots": { },
- "children": [
- { }
]
}
]
}, - "draft_updated_at": "2026-07-20T10:00:00.000Z",
- "published_at": "2026-07-20T10:00:00.000Z",
- "created_from_template": { },
- "active": true
}, - "customization_model": "page",
- "dynamic": {
- "cs_template": "string",
- "dynamic_informations": { },
- "dynamic_links": [
- { }
]
}, - "engine": {
- "contract_url": "/api/v1/landing-engine",
- "version": "1.0.0"
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Replace the whole page. Runs the exact pipeline the dashboard editor uses (structural validation, href normalization, a 2 MB cap, a version snapshot, and the mandatory edge re-mirror). For a link, a write is publish + activate in one shot - the page goes live immediately.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff The unique identifier of the link. |
required | object (Page) A Landing v2 page: a theme/meta envelope plus an ordered list of sections. You do not have to memorize the section shapes - GET /api/v1/landing-engine returns the full, always-current catalog of section types (layouts, content slots, style keys), and GET /api/v1/landing-engine/example returns a ready-to-PUT starter page. Authoring is additive: only the page skeleton (a sections array, up to 500 sections, one level of nesting) and a 2 MB size cap are enforced; unknown style/content keys flow through untouched. | ||||||||||
| |||||||||||
{- "page": {
- "version": 2,
- "theme": {
- "color_primary": "#2563eb"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "slots": { }
}
]
}
}{- "ok": true,
- "landing": {
- "version": 2,
- "state_written": "published",
- "draft_updated_at": "2026-07-20T12:00:00.000Z",
- "published_at": "2026-07-20T12:00:00.000Z",
- "active": true
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Targeted merge - send only the top-level page keys you want to change. theme and meta are merged one level deep; sections, platform_groups, and version are replaced wholesale (arrays are never element-merged). PATCH amends the currently published page and is not a create: it returns 409 if the resource has no Landing v2 page yet (use PUT first).
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff The unique identifier of the link. |
required | object (Page) A Landing v2 page: a theme/meta envelope plus an ordered list of sections. You do not have to memorize the section shapes - GET /api/v1/landing-engine returns the full, always-current catalog of section types (layouts, content slots, style keys), and GET /api/v1/landing-engine/example returns a ready-to-PUT starter page. Authoring is additive: only the page skeleton (a sections array, up to 500 sections, one level of nesting) and a 2 MB size cap are enforced; unknown style/content keys flow through untouched. | ||||||||||
| |||||||||||
{- "page": {
- "theme": {
- "color_primary": "#1d4ed8"
}
}
}{- "ok": true,
- "landing": {
- "version": 2,
- "state_written": "published",
- "draft_updated_at": "2026-07-20T12:00:00.000Z",
- "published_at": "2026-07-20T12:00:00.000Z",
- "active": true
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Every write snapshots the page (rolling window of the last 60, newest first). Add ?include_pages=false for a lightweight metadata list. There is no restore endpoint - roll back by reading an old version and PUT-ting its page back.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff The unique identifier of the link. |
| include_pages | boolean Default: true Set to false to omit each version's page (metadata only). |
{- "versions": [
- {
- "id": "6650c3aa11cc22dd33ee44ff",
- "saved_at": "2026-07-20T12:00:00.000Z",
- "source": "api",
- "page": {
- "id": "page_ab12cd",
- "version": 2,
- "theme": {
- "font_family": "inter",
- "color_primary": "#2563eb",
- "color_text": "#0f172a",
- "color_bg": "#ffffff",
- "radius_scale": "soft"
}, - "meta": {
- "title": "Ana",
- "description": "Creator links",
- "og_image_uploadcare_uuid": "string",
- "favicon_uploadcare_uuid": "string"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "background": { },
- "style": { },
- "slots": { },
- "children": [
- { }
]
}
]
}
}
], - "count": 3,
- "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Return a single history snapshot with its full, un-folded page.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff The unique identifier of the link. |
| version_id required | string Example: 6650c3aa11cc22dd33ee44ff The snapshot id (from the history list). |
{- "version": {
- "id": "6650c3aa11cc22dd33ee44ff",
- "saved_at": "2026-07-20T12:00:00.000Z",
- "source": "api",
- "page": {
- "id": "page_ab12cd",
- "version": 2,
- "theme": {
- "font_family": "inter",
- "color_primary": "#2563eb",
- "color_text": "#0f172a",
- "color_bg": "#ffffff",
- "radius_scale": "soft"
}, - "meta": {
- "title": "Ana",
- "description": "Creator links",
- "og_image_uploadcare_uuid": "string",
- "favicon_uploadcare_uuid": "string"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "background": { },
- "style": { },
- "slots": { },
- "children": [
- { }
]
}
]
}
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Read the Model B per-link override: the shared-template reference (cs_template) plus the per-link dynamic_informations (name / photo) and dynamic_links.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff The unique identifier of the link. |
{- "ok": true,
- "dynamic_overrides": {
- "cs_template": "6650b2aa11cc22dd33ee44ff",
- "dynamic_informations": { },
- "dynamic_links": [
- { }
]
}, - "customization_model": "template_dynamic",
- "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Full replace of the { cs_template, dynamic_informations, dynamic_links } triplet - any field you omit is cleared. PUT {} resets the link to no overrides and detaches the template. This is the only API surface that can (re)attach or detach a shared template on an existing link. Every write re-mirrors the edge, so the change is live immediately.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff The unique identifier of the link. |
| cs_template | string or null The shared template's ObjectId (attaches the design). null detaches. |
object or null Overrides the first hero section (name / photo). | |
Array of objects or null Replaces the first links_list section's items (content only; the template design is untouched). |
{- "cs_template": "6650b2aa11cc22dd33ee44ff",
}{- "ok": true,
- "dynamic_overrides": {
- "cs_template": "6650b2aa11cc22dd33ee44ff",
- "dynamic_informations": { },
- "dynamic_links": [
- { }
]
}, - "customization_model": "template_dynamic",
- "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Merge - only the keys you send change. dynamic_informations is merged one level deep (send just { dynamic_informations: { n: "New name" } } to rename without touching the photo); dynamic_links is replaced wholesale. Send cs_template: null to detach. An empty PATCH returns 400.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff The unique identifier of the link. |
| cs_template | string or null The shared template's ObjectId (attaches the design). null detaches. |
object or null Overrides the first hero section (name / photo). | |
Array of objects or null Replaces the first links_list section's items (content only; the template design is untouched). |
{- "dynamic_informations": {
- "n": "Ana Smith"
}
}{- "ok": true,
- "dynamic_overrides": {
- "cs_template": "6650b2aa11cc22dd33ee44ff",
- "dynamic_informations": { },
- "dynamic_links": [
- { }
]
}, - "customization_model": "template_dynamic",
- "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Return a template's Landing v2 page. Same shape as the link read, minus the link-only fields (active, dynamic, and a real preview_url).
| template_id required | string Example: 6650b2aa11cc22dd33ee44ff The unique identifier of the template. |
| state | string Default: "published" Enum: "published" "draft" |
{- "landing": {
- "version": 2,
- "state_returned": "published",
- "page": {
- "id": "page_ab12cd",
- "version": 2,
- "theme": {
- "font_family": "inter",
- "color_primary": "#2563eb",
- "color_text": "#0f172a",
- "color_bg": "#ffffff",
- "radius_scale": "soft"
}, - "meta": {
- "title": "Ana",
- "description": "Creator links",
- "og_image_uploadcare_uuid": "string",
- "favicon_uploadcare_uuid": "string"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "background": { },
- "style": { },
- "slots": { },
- "children": [
- { }
]
}
]
}, - "draft_updated_at": "2026-07-20T10:00:00.000Z",
- "published_at": "2026-07-20T10:00:00.000Z",
- "created_from_template": { },
- "active": true
}, - "customization_model": "page",
- "dynamic": {
- "cs_template": "string",
- "dynamic_informations": { },
- "dynamic_links": [
- { }
]
}, - "engine": {
- "contract_url": "/api/v1/landing-engine",
- "version": "1.0.0"
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Replace the whole page. For a template, a write publishes and marks it as a v2 template (there is no active flag and no preview_url).
| template_id required | string Example: 6650b2aa11cc22dd33ee44ff The unique identifier of the template. |
required | object (Page) A Landing v2 page: a theme/meta envelope plus an ordered list of sections. You do not have to memorize the section shapes - GET /api/v1/landing-engine returns the full, always-current catalog of section types (layouts, content slots, style keys), and GET /api/v1/landing-engine/example returns a ready-to-PUT starter page. Authoring is additive: only the page skeleton (a sections array, up to 500 sections, one level of nesting) and a 2 MB size cap are enforced; unknown style/content keys flow through untouched. | ||||||||||
| |||||||||||
{- "page": {
- "id": "page_ab12cd",
- "version": 2,
- "theme": {
- "font_family": "inter",
- "color_primary": "#2563eb",
- "color_text": "#0f172a",
- "color_bg": "#ffffff",
- "radius_scale": "soft"
}, - "meta": {
- "title": "Ana",
- "description": "Creator links",
- "og_image_uploadcare_uuid": "string",
- "favicon_uploadcare_uuid": "string"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "background": { },
- "style": { },
- "slots": { },
- "children": [
- { }
]
}
]
}
}{- "ok": true,
- "landing": {
- "version": 2,
- "state_written": "published",
- "draft_updated_at": "2026-07-20T12:00:00.000Z",
- "published_at": "2026-07-20T12:00:00.000Z",
- "active": true
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Targeted merge (same semantics as the link PATCH). Returns 409 if the template has no Landing v2 page yet.
| template_id required | string Example: 6650b2aa11cc22dd33ee44ff The unique identifier of the template. |
required | object (Page) A Landing v2 page: a theme/meta envelope plus an ordered list of sections. You do not have to memorize the section shapes - GET /api/v1/landing-engine returns the full, always-current catalog of section types (layouts, content slots, style keys), and GET /api/v1/landing-engine/example returns a ready-to-PUT starter page. Authoring is additive: only the page skeleton (a sections array, up to 500 sections, one level of nesting) and a 2 MB size cap are enforced; unknown style/content keys flow through untouched. | ||||||||||
| |||||||||||
{- "page": {
- "id": "page_ab12cd",
- "version": 2,
- "theme": {
- "font_family": "inter",
- "color_primary": "#2563eb",
- "color_text": "#0f172a",
- "color_bg": "#ffffff",
- "radius_scale": "soft"
}, - "meta": {
- "title": "Ana",
- "description": "Creator links",
- "og_image_uploadcare_uuid": "string",
- "favicon_uploadcare_uuid": "string"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "background": { },
- "style": { },
- "slots": { },
- "children": [
- { }
]
}
]
}
}{- "ok": true,
- "landing": {
- "version": 2,
- "state_written": "published",
- "draft_updated_at": "2026-07-20T12:00:00.000Z",
- "published_at": "2026-07-20T12:00:00.000Z",
- "active": true
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Snapshots newest first (rolling window of the last 60). ?include_pages=false for a metadata-only list.
| template_id required | string Example: 6650b2aa11cc22dd33ee44ff The unique identifier of the template. |
| include_pages | boolean Default: true |
{- "versions": [
- {
- "id": "6650c3aa11cc22dd33ee44ff",
- "saved_at": "2026-07-20T12:00:00.000Z",
- "source": "api",
- "page": {
- "id": "page_ab12cd",
- "version": 2,
- "theme": {
- "font_family": "inter",
- "color_primary": "#2563eb",
- "color_text": "#0f172a",
- "color_bg": "#ffffff",
- "radius_scale": "soft"
}, - "meta": {
- "title": "Ana",
- "description": "Creator links",
- "og_image_uploadcare_uuid": "string",
- "favicon_uploadcare_uuid": "string"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "background": { },
- "style": { },
- "slots": { },
- "children": [
- { }
]
}
]
}
}
], - "count": 3,
- "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Return a single history snapshot with its full page.
| template_id required | string Example: 6650b2aa11cc22dd33ee44ff The unique identifier of the template. |
| version_id required | string Example: 6650c3aa11cc22dd33ee44ff The snapshot id. |
{- "version": {
- "id": "6650c3aa11cc22dd33ee44ff",
- "saved_at": "2026-07-20T12:00:00.000Z",
- "source": "api",
- "page": {
- "id": "page_ab12cd",
- "version": 2,
- "theme": {
- "font_family": "inter",
- "color_primary": "#2563eb",
- "color_text": "#0f172a",
- "color_bg": "#ffffff",
- "radius_scale": "soft"
}, - "meta": {
- "title": "Ana",
- "description": "Creator links",
- "og_image_uploadcare_uuid": "string",
- "favicon_uploadcare_uuid": "string"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "background": { },
- "style": { },
- "slots": { },
- "children": [
- { }
]
}
]
}
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}A machine-readable description of the Page JSON we render: the page/theme envelope plus the full live catalog of section types (every type's layouts, content slots with value shapes, and style keys). Generated from the same section registry the editor uses, so it never drifts from what actually renders. Cacheable for 5 minutes.
| section | string Example: section=hero Filter the catalog to a single section type (e.g. hero). An unknown type returns 404. |
{- "engine": {
- "contract_version": "1.0.0",
- "generated_from": "live-registry",
- "notation": "string",
- "page_schema": { },
- "section_schema": { },
- "slot_value_shapes": { },
- "section_types": [
- {
- "type": "hero",
- "label": "Hero",
- "category": "header",
- "layouts": [
- "full_bleed",
- "centered",
- "split",
- "profile_card"
], - "slots": [
- { }
], - "style_groups": [
- { }
], - "accepts_children": true,
- "allowed_child_types": [
- "string"
]
}
]
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}A ready-to-PUT example page (hero + links_list) to bootstrap integrators. Cacheable for 5 minutes.
{- "example": {
- "contract_version": "1.0.0",
- "page": {
- "id": "page_ab12cd",
- "version": 2,
- "theme": {
- "font_family": "inter",
- "color_primary": "#2563eb",
- "color_text": "#0f172a",
- "color_bg": "#ffffff",
- "radius_scale": "soft"
}, - "meta": {
- "title": "Ana",
- "description": "Creator links",
- "og_image_uploadcare_uuid": "string",
- "favicon_uploadcare_uuid": "string"
}, - "sections": [
- {
- "id": "sec_1",
- "type": "hero",
- "layout": "centered",
- "background": { },
- "style": { },
- "slots": { },
- "children": [
- { }
]
}
]
}, - "usage": "PUT this `page` to /api/v1/links/{link_id}/landing or /api/v1/templates/{template_id}/landing to publish it."
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Everything you need to build a valid Shield configuration without hard-coding enums: every condition type and the values it accepts, the three actions, the block screens, the simple buckets, the presets and the registered-bot registry. Generated from the same catalogues the dashboard renders, so it always matches the live product. Static — cache it.
{- "version": "2.0.0",
- "model": {
- "summary": "An ordered list of rules, each WHEN -> THEN. First enabled match wins.",
- "evaluation": "first_match_wins",
- "limits": {
- "max_rules": 200,
- "max_condition_depth": 5,
- "max_values_per_condition": 200
}, - "notes": [
- "string"
]
}, - "actions": [
- {
- "type": "block",
- "description": "string"
}
], - "block_screens": {
- "link_default": [
- {
- "behavior": "string",
- "description": "string"
}
], - "per_rule": [
- {
- "behavior": "string",
- "description": "string"
}
], - "decoy_sources": [
- {
- "source": "string",
- "description": "string"
}
]
}, - "condition_types": [
- {
- "type": "NETWORK",
- "description": "string",
- "value_kind": "enum",
- "operators": [
- "is",
- "is_not"
], - "values": [
- "proxy",
- "vpn",
- "datacenter"
], - "value_labels": {
- "property1": "string",
- "property2": "string"
}
}
], - "buckets": {
- "description": "string",
- "actions": [
- "off",
- "block",
- "allow",
- "redirect"
], - "items": [
- {
- "key": "net_vpn",
- "group": "network",
- "condition": {
- "type": "NETWORK",
- "values": [
- "vpn"
]
}, - "description": "Traffic coming through a VPN service."
}
]
}, - "presets": [
- {
- "id": "instagram",
- "name": "Instagram Optimized",
- "description": "Bots and anonymized traffic see your landing — your real page stays hidden from link scanners.",
- "block_screen": "landing",
- "default_decoy_source": "link",
- "buckets": {
- "bot_known": "block",
- "bot_unknown": "block",
- "net_proxy": "block",
- "net_vpn": "block",
- "net_datacenter": "block"
}, - "cuts_deeplinks": true
}
], - "bots": {
- "description": "string",
- "version": "2.0.0",
- "kinds": [
- {
- "kind": "social",
- "label": "Social previews"
}
], - "items": [
- {
- "id": "facebook",
- "name": "Facebook / Instagram / Threads",
- "kind": "social"
}
]
}
}The one-click protection profiles you can apply with {"preset": "<id>"} on any Shield write, plus the simple buckets each of them configures.
{- "version": "2.0.0",
- "presets": [
- {
- "id": "instagram",
- "name": "Instagram Optimized",
- "description": "Bots and anonymized traffic see your landing — your real page stays hidden from link scanners.",
- "block_screen": "landing",
- "default_decoy_source": "link",
- "buckets": {
- "bot_known": "block",
- "bot_unknown": "block",
- "net_proxy": "block",
- "net_vpn": "block",
- "net_datacenter": "block"
}, - "cuts_deeplinks": true
}, - {
- "id": "bots_only",
- "name": "Bots → 3 dots",
- "description": "Send every bot to the 3-dots \"open in browser\" overlay and leave real human proxy / VPN traffic untouched.",
- "block_screen": "three_dots",
- "default_decoy_source": "template",
- "buckets": {
- "bot_known": "block",
- "bot_unknown": "block"
}, - "cuts_deeplinks": false
}, - {
- "id": "hard_404",
- "name": "Hard block (404)",
- "description": "Block bots, proxies, VPNs and datacenter IPs with a plain 404 — no landing needed.",
- "block_screen": "not_found",
- "default_decoy_source": "template",
- "buckets": {
- "bot_known": "block",
- "bot_unknown": "block",
- "net_proxy": "block",
- "net_vpn": "block",
- "net_datacenter": "block"
}, - "cuts_deeplinks": false
}
]
}The crawlers LinkScale recognizes by name. Use an id as a BOT condition value, or as a bot:<id> bucket, to give one specific crawler its own outcome. Anything automated that is not in this list is unknown.
{- "description": "string",
- "version": "2.0.0",
- "kinds": [
- {
- "kind": "social",
- "label": "Social previews"
}
], - "items": [
- {
- "id": "facebook",
- "name": "Facebook / Instagram / Threads",
- "kind": "social"
}
]
}Return the link's complete Shield config: the ordered rules (each with a plain-English summary), the same configuration expressed as simple buckets, the default block screen, the applied preset and a readiness verdict.
Links that have never been migrated to the rule engine return model: "legacy" together with the deterministic compilation of their old tri-state fields — that is exactly what the dashboard opens, and exactly what the serve layer falls back to.
The decoy page snapshot is omitted by default (it can be hundreds of KB); pass ?include_page=true to get it inline.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff The unique identifier of the link. |
| include_page | boolean Default: false Include the decoy page snapshot in the response. |
{- "shield": {
- "enabled": true,
- "preset": "instagram",
- "model": "v2",
- "mode": "simple",
- "rules": [
- {
- "id": "simple_bot_known",
- "enabled": true,
- "label": "",
- "match": {
- "op": "any",
- "rules": [
- {
- "type": "BOT",
- "match": "is",
- "values": [
- "known"
]
}
]
}, - "action": {
- "type": "block"
}, - "summary": "registered bots"
}
], - "buckets": {
- "bot_known": {
- "action": "block"
}, - "bot_unknown": {
- "action": "block"
}, - "net_proxy": {
- "action": "block"
}, - "net_vpn": {
- "action": "block"
}, - "net_datacenter": {
- "action": "block"
}
}, - "block": {
- "behavior": "landing",
- "landing": {
- "source": "link",
- "link_u": "john",
- "link_domain": "lnkdm.me",
- "link_name": "John's page",
- "has_page_snapshot": false
}
}, - "ready": true,
- "issues": [ ],
- "stats": {
- "rules_total": 5,
- "rules_enabled": 5,
- "custom_rules": 0,
- "blocks": 5,
- "allows": 0,
- "redirects": 0
}
}, - "contract": {
- "url": "/api/v1/shield",
- "version": "2.0.0"
}, - "link": {
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "u": "john",
- "domain": "lnkdm.me"
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}
}Replace the whole config: anything you omit is reset. Use it to set a link's protection from scratch — {"enabled": true, "preset": "instagram"} is a complete, serveable setup in one call.
Decoy references are resolved server-side: send template_id and the API attaches the template's name plus a page snapshot as the serve-time fallback; send {"source": "self"} and the link's own landing is used. A configuration whose decoy has no page to render is rejected with 400 rather than silently degrading to a 404.
The write goes through the same pipeline as a dashboard save, including the mandatory edge re-mirror, so it is live immediately.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff |
| enabled | boolean Master switch. Shield never runs on a link where this is false. |
| preset | string Enum: "instagram" "bots_only" "hard_404" Apply a one-click protection profile — it generates the rules and the block screen for you. Send null to clear the recorded preset. See GET /api/v1/shield/presets. |
Array of objects (ShieldRule) The full ordered rule list (max 200). Replaces whatever is stored. | |
object Simple mode: set common traffic buckets without writing rules. Each bucket is backed by ONE real rule, so anything you set here also shows up in | |
object (ShieldBlockScreen) What a blocked visitor is served. Used both as the link's DEFAULT screen ( |
{- "enabled": true,
- "preset": "instagram"
}{- "ok": true,
- "shield": {
- "enabled": true,
- "preset": "instagram",
- "model": "v2",
- "mode": "simple",
- "rules": [
- {
- "id": "simple_bot_known",
- "enabled": true,
- "label": "Link scanners",
- "match": {
- "op": "any",
- "rules": [
- {
- "type": "BOT",
- "match": "is",
- "values": [
- "known"
]
}, - {
- "type": "NETWORK",
- "match": "is",
- "values": [
- "vpn",
- "datacenter"
]
}
]
}, - "action": {
- "type": "block",
- "block": {
- "behavior": "landing",
- "landing": {
- "source": "template",
- "template_id": "692d91eec003c7d6b3ab6273",
- "template_name": "Clean bio page",
- "template_generated": true,
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "link_u": "john",
- "link_domain": "lnkdm.me",
- "link_name": "John's page",
- "has_page_snapshot": true,
- "page": { }
}
}, - "deeplinks_logic": {
- "instagram_landing": {
- "type": "open_in_webview"
}, - "direct_browser_open": {
- "type": "without_deeplink"
}
}
}, - "summary": "registered bots or VPN",
- "warning": {
- "kind": "empty",
- "message": "No condition yet — this rule never matches."
}
}
], - "buckets": {
- "bot_known": {
- "action": "block"
}, - "net_vpn": {
- "action": "block"
}
}, - "block": {
- "behavior": "landing",
- "landing": {
- "source": "template",
- "template_id": "692d91eec003c7d6b3ab6273",
- "template_name": "Clean bio page",
- "template_generated": true,
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "link_u": "john",
- "link_domain": "lnkdm.me",
- "link_name": "John's page",
- "has_page_snapshot": true,
- "page": { }
}
}, - "ready": true,
- "issues": [
- {
- "scope": "default",
- "message": "string"
}
], - "stats": {
- "rules_total": 5,
- "rules_enabled": 5,
- "custom_rules": 0,
- "blocks": 5,
- "allows": 0,
- "redirects": 0
}, - "legacy": {
- "vpn": "off",
- "proxy": "off",
- "bot_known": "off",
- "bot_unknown": "off",
- "bot_rules": {
- "property1": "string",
- "property2": "string"
}, - "block_vpn": true,
- "block_proxy": true
}
}, - "updated_fields": [
- "shield",
- "shield_rules",
- "shield_block",
- "shield_preset"
], - "contract": {
- "url": "/api/v1/shield",
- "version": "2.0.0"
}, - "link": {
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "u": "john",
- "domain": "lnkdm.me"
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}, - "message": "Shield configuration saved"
}Same body as PUT, but only the keys you send are touched — everything else keeps its stored value, and block is merged one level deep instead of replaced. Use it to flip a single bucket, swap the decoy or turn Shield off without restating the whole config.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff |
| enabled | boolean Master switch. Shield never runs on a link where this is false. |
| preset | string Enum: "instagram" "bots_only" "hard_404" Apply a one-click protection profile — it generates the rules and the block screen for you. Send null to clear the recorded preset. See GET /api/v1/shield/presets. |
Array of objects (ShieldRule) The full ordered rule list (max 200). Replaces whatever is stored. | |
object Simple mode: set common traffic buckets without writing rules. Each bucket is backed by ONE real rule, so anything you set here also shows up in | |
object (ShieldBlockScreen) What a blocked visitor is served. Used both as the link's DEFAULT screen ( |
{- "buckets": {
- "bot:googlebot": "allow"
}
}{- "ok": true,
- "shield": {
- "enabled": true,
- "preset": "instagram",
- "model": "v2",
- "mode": "simple",
- "rules": [
- {
- "id": "simple_bot_known",
- "enabled": true,
- "label": "Link scanners",
- "match": {
- "op": "any",
- "rules": [
- {
- "type": "BOT",
- "match": "is",
- "values": [
- "known"
]
}, - {
- "type": "NETWORK",
- "match": "is",
- "values": [
- "vpn",
- "datacenter"
]
}
]
}, - "action": {
- "type": "block",
- "block": {
- "behavior": "landing",
- "landing": {
- "source": "template",
- "template_id": "692d91eec003c7d6b3ab6273",
- "template_name": "Clean bio page",
- "template_generated": true,
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "link_u": "john",
- "link_domain": "lnkdm.me",
- "link_name": "John's page",
- "has_page_snapshot": true,
- "page": { }
}
}, - "deeplinks_logic": {
- "instagram_landing": {
- "type": "open_in_webview"
}, - "direct_browser_open": {
- "type": "without_deeplink"
}
}
}, - "summary": "registered bots or VPN",
- "warning": {
- "kind": "empty",
- "message": "No condition yet — this rule never matches."
}
}
], - "buckets": {
- "bot_known": {
- "action": "block"
}, - "net_vpn": {
- "action": "block"
}
}, - "block": {
- "behavior": "landing",
- "landing": {
- "source": "template",
- "template_id": "692d91eec003c7d6b3ab6273",
- "template_name": "Clean bio page",
- "template_generated": true,
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "link_u": "john",
- "link_domain": "lnkdm.me",
- "link_name": "John's page",
- "has_page_snapshot": true,
- "page": { }
}
}, - "ready": true,
- "issues": [
- {
- "scope": "default",
- "message": "string"
}
], - "stats": {
- "rules_total": 5,
- "rules_enabled": 5,
- "custom_rules": 0,
- "blocks": 5,
- "allows": 0,
- "redirects": 0
}, - "legacy": {
- "vpn": "off",
- "proxy": "off",
- "bot_known": "off",
- "bot_unknown": "off",
- "bot_rules": {
- "property1": "string",
- "property2": "string"
}, - "block_vpn": true,
- "block_proxy": true
}
}, - "updated_fields": [
- "shield",
- "shield_rules",
- "shield_block",
- "shield_preset"
], - "contract": {
- "url": "/api/v1/shield",
- "version": "2.0.0"
}, - "link": {
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "u": "john",
- "domain": "lnkdm.me"
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}, - "message": "Shield configuration saved"
}Turn Shield off and wipe everything it stored: rules, default block screen, applied preset and the legacy tri-state fields (so nothing can be resurrected by the legacy fallback). The link itself is untouched. To pause protection while keeping the setup, send PATCH {"enabled": false} instead.
| link_id required | string Example: 6650a1bb22cc33dd44ee55ff |
{- "ok": true,
- "shield": {
- "enabled": true,
- "preset": "instagram",
- "model": "v2",
- "mode": "simple",
- "rules": [
- {
- "id": "simple_bot_known",
- "enabled": true,
- "label": "Link scanners",
- "match": {
- "op": "any",
- "rules": [
- {
- "type": "BOT",
- "match": "is",
- "values": [
- "known"
]
}, - {
- "type": "NETWORK",
- "match": "is",
- "values": [
- "vpn",
- "datacenter"
]
}
]
}, - "action": {
- "type": "block",
- "block": {
- "behavior": "landing",
- "landing": {
- "source": "template",
- "template_id": "692d91eec003c7d6b3ab6273",
- "template_name": "Clean bio page",
- "template_generated": true,
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "link_u": "john",
- "link_domain": "lnkdm.me",
- "link_name": "John's page",
- "has_page_snapshot": true,
- "page": { }
}
}, - "deeplinks_logic": {
- "instagram_landing": {
- "type": "open_in_webview"
}, - "direct_browser_open": {
- "type": "without_deeplink"
}
}
}, - "summary": "registered bots or VPN",
- "warning": {
- "kind": "empty",
- "message": "No condition yet — this rule never matches."
}
}
], - "buckets": {
- "bot_known": {
- "action": "block"
}, - "net_vpn": {
- "action": "block"
}
}, - "block": {
- "behavior": "landing",
- "landing": {
- "source": "template",
- "template_id": "692d91eec003c7d6b3ab6273",
- "template_name": "Clean bio page",
- "template_generated": true,
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "link_u": "john",
- "link_domain": "lnkdm.me",
- "link_name": "John's page",
- "has_page_snapshot": true,
- "page": { }
}
}, - "ready": true,
- "issues": [
- {
- "scope": "default",
- "message": "string"
}
], - "stats": {
- "rules_total": 5,
- "rules_enabled": 5,
- "custom_rules": 0,
- "blocks": 5,
- "allows": 0,
- "redirects": 0
}, - "legacy": {
- "vpn": "off",
- "proxy": "off",
- "bot_known": "off",
- "bot_unknown": "off",
- "bot_rules": {
- "property1": "string",
- "property2": "string"
}, - "block_vpn": true,
- "block_proxy": true
}
}, - "updated_fields": [
- "shield",
- "shield_rules",
- "shield_block",
- "shield_preset"
], - "contract": {
- "url": "/api/v1/shield",
- "version": "2.0.0"
}, - "link": {
- "link_id": "6650a1bb22cc33dd44ee55ff",
- "u": "john",
- "domain": "lnkdm.me"
}, - "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}, - "message": "Shield configuration saved"
}Generate a secure signature for uploading files directly to Uploadcare CDN.
Call this endpoint to get a secure upload signature that expires after your specified time (default: 10 minutes).
const response = await fetch('https://dashboard.linkscale.to/api/v1/assets', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
},
body: JSON.stringify({
expiration_minutes: 10
})
});
const { upload_config, project } = await response.json();
Use the signature to upload your file directly to Uploadcare using multipart/form-data.
Required FormData Fields:
UPLOADCARE_PUB_KEY: Public key from upload_configUPLOADCARE_STORE: Set to 'auto' for automatic storagesignature: Secure signature from upload_configexpire: Unix timestamp from upload_configfile: Your file as Blob/File objectmetadata[project_id]: Project ID from upload_config.metadatametadata[api_key_id]: API key ID from upload_config.metadataSupported File Types:
Complete Upload Example (Browser):
const formData = new FormData();
formData.append('UPLOADCARE_PUB_KEY', upload_config.public_key);
formData.append('UPLOADCARE_STORE', 'auto');
formData.append('signature', upload_config.signature);
formData.append('expire', upload_config.expire.toString());
formData.append('file', fileInput.files[0]); // Browser File object
formData.append('metadata[project_id]', upload_config.metadata.project_id);
formData.append('metadata[api_key_id]', upload_config.metadata.api_key_id);
const uploadResponse = await fetch(upload_config.upload_url, {
method: 'POST',
body: formData
});
const { file: fileId } = await uploadResponse.json();
console.log('File ID:', fileId); // e.g., "17be4678-dab7-4bc7-8753-28914a22960a"
Complete Upload Example (Node.js):
const fs = require('fs');
const path = require('path');
// Read file and create Blob
const fileBuffer = fs.readFileSync('./image.jpg');
const fileName = path.basename('./image.jpg');
// Detect MIME type from extension
const mimeTypes = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.pdf': 'application/pdf',
'.json': 'application/json',
'.txt': 'text/plain'
};
const fileExtension = path.extname('./image.jpg').toLowerCase();
const mimeType = mimeTypes[fileExtension] || 'application/octet-stream';
const fileBlob = new Blob([fileBuffer], { type: mimeType });
// Create FormData with all required fields
const formData = new FormData();
formData.append('UPLOADCARE_PUB_KEY', upload_config.public_key);
formData.append('UPLOADCARE_STORE', 'auto');
formData.append('signature', upload_config.signature);
formData.append('expire', upload_config.expire.toString());
formData.append('file', fileBlob, fileName);
formData.append('metadata[project_id]', upload_config.metadata.project_id);
formData.append('metadata[api_key_id]', upload_config.metadata.api_key_id);
const uploadResponse = await fetch(upload_config.upload_url, {
method: 'POST',
body: formData
});
const { file: fileId } = await uploadResponse.json();
After uploading, poll GET /api/v1/assets/{file_id} until the file is validated (usually 2-3 seconds).
const pollValidation = async (fileId, maxAttempts = 10, delayMs = 2000) => {
for (let i = 0; i < maxAttempts; i++) {
const response = await fetch(`https://dashboard.linkscale.to/api/v1/assets/${fileId}`, {
headers: { 'Authorization': 'Bearer YOUR_API_KEY' }
});
if (response.ok) {
const data = await response.json();
console.log('✅ File validated!');
return data; // Asset ready to use
}
if (response.status === 404) {
console.log(`⏳ Still processing... (${i + 1}/${maxAttempts})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
continue;
}
throw new Error('Validation failed');
}
throw new Error('Timeout - file may still be processing');
};
const asset = await pollValidation(fileId);
console.log('CDN URL:', asset.asset.provider_file_url);
Once validated, use the provider_file_url from the asset object to access your file via CDN.
// Use in your application
const cdnUrl = asset.asset.provider_file_url;
// Example: "https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/"
Images:
image/png - PNG imagesimage/jpeg - JPEG imagesimage/gif - GIF imagesimage/webp - WebP imagesimage/svg+xml - SVG imagesVideos:
video/mp4 - MP4 videosvideo/webm - WebM videosDocuments:
application/pdf - PDF documentsapplication/json - JSON filesText:
text/plain - Text filestext/csv - CSV filesSignature Request Errors:
Upload Errors:
Validation Errors:
| expiration_minutes | integer [ 1 .. 60 ] Default: 10 Signature expiration time in minutes (1-60). After this time, the signature becomes invalid and cannot be used for uploads. Recommended: 10 minutes for standard uploads, 30 minutes for large files. |
| allowed_mime_types | Array of strings or null Optional array of allowed MIME types to restrict uploads. If specified, only files matching these MIME types can be uploaded. If null/omitted, all file types are allowed. Common MIME types:
|
| max_file_size | integer or null Optional maximum file size in bytes. If specified, files larger than this size will be rejected. If null/omitted, no size limit is enforced. Recommended limits:
|
{- "expiration_minutes": 10
}{- "upload_config": {
- "public_key": "demopublickey",
- "expire": 1728481200,
- "signature": "a8b7c6d5e4f3g2h1i0j9k8l7m6n5o4p3",
- "metadata": {
- "project_id": "proj_abc123def456",
- "api_key_id": "lk_xyz789abc123"
}
}, - "project": {
- "project_id": "proj_abc123def456",
- "project_name": "My Project"
}
}Retrieve a paginated list of all validated assets for your project with optional search and filtering.
Features:
File Type Categories:
image: PNG, JPEG, GIF, WebP, SVG, etc.video: MP4, WebM, MOV, AVI, etc.audio: MP3, WAV, OGG, M4A, etc.application: PDF, ZIP, JSON, XML, etc.text: TXT, CSV, HTML, CSS, etc.Use Cases:
| search | string <= 100 characters Example: search=logo Search in filename and MIME type (partial match, case-insensitive, max 100 chars) |
| file_type | string Enum: "image" "video" "audio" "application" "text" Example: file_type=image Filter by file category |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Number of results per page (1-100) |
| offset | integer >= 0 Default: 0 Number of items to skip for pagination |
{- "assets": [
- {
- "_id": "67890xyz",
- "project_id": "proj_abc123",
- "file_id": "17be4678-dab7-4bc7-8753-28914a22960a",
- "provider_file_name": "logo.png",
- "file_mime_type": "image/png",
- "provider_date_time_uploaded": "2025-10-09T10:30:00.000Z",
- "file_size": 123456,
- "image_info": {
- "width": 1920,
- "height": 1080,
- "format": "PNG"
}, - "created_at": "2025-10-09T10:30:05.000Z"
}, - {
- "_id": "67891abc",
- "project_id": "proj_abc123",
- "file_id": "28cf5789-ebc8-5cd8-9864-39a25b33a71b",
- "provider_file_name": "video.mp4",
- "file_mime_type": "video/mp4",
- "provider_date_time_uploaded": "2025-10-09T11:15:00.000Z",
- "file_size": 5242880,
- "video_info": {
- "duration": 30000,
- "bitrate": 1400000
}, - "created_at": "2025-10-09T11:15:03.000Z"
}
], - "total": 42,
- "limit": 50,
- "offset": 0,
- "project": {
- "project_id": "proj_abc123",
- "project_name": "My Project"
}
}Retrieve information about a specific asset by its Uploadcare file ID.
After uploading a file to Uploadcare, you MUST poll this endpoint to verify the file has been validated by the webhook.
The upload process is asynchronous:
Processing includes:
Upload to Uploadcare → Get file_id → Poll this endpoint → 200 OK → Use asset
↓
404 = Still processing (wait 2s, retry)
Basic Polling (Recommended):
const pollValidation = async (fileId, maxAttempts = 10, delayMs = 2000) => {
console.log('⏳ Polling for validation...');
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(
`https://dashboard.linkscale.to/api/v1/assets/${fileId}`,
{
method: 'GET',
headers: {
'Authorization': 'Bearer YOUR_API_KEY'
}
}
);
if (response.ok) {
const data = await response.json();
console.log('✅ File validated and ready!');
console.log('CDN URL:', data.asset.provider_file_url);
return data;
}
if (response.status === 404) {
console.log(`Attempt ${i + 1}/${maxAttempts}: Still processing...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
continue;
}
// Other error
const errorText = await response.text();
throw new Error(`Validation check failed: ${response.status} - ${errorText}`);
} catch (error) {
console.warn(`Attempt ${i + 1}/${maxAttempts} error:`, error.message);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw new Error('Validation timeout - file may still be processing or failed');
};
// Usage
try {
const asset = await pollValidation(fileId);
// Asset is ready! Use the CDN URL
const cdnUrl = asset.asset.provider_file_url;
console.log('Use this URL:', cdnUrl);
} catch (error) {
console.error('Upload failed:', error.message);
}
Advanced Polling with Exponential Backoff:
const pollValidationWithBackoff = async (fileId) => {
const delays = [1000, 2000, 2000, 3000, 5000]; // Progressive delays
for (let i = 0; i < delays.length; i++) {
const response = await fetch(
`https://dashboard.linkscale.to/api/v1/assets/${fileId}`,
{ headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }
);
if (response.ok) {
return await response.json(); // ✅ Success!
}
if (response.status === 404 && i < delays.length - 1) {
console.log(`⏳ Waiting ${delays[i]}ms...`);
await new Promise(r => setTimeout(r, delays[i]));
continue;
}
if (response.status !== 404) {
throw new Error(`Validation failed: ${response.status}`);
}
}
throw new Error('Timeout after multiple attempts');
};
Once validated (200 OK), the response includes:
provider_file_url for accessing the file404 Response: File not yet validated
401 Response: Unauthorized
Other errors: Validation failed
const uploadFile = async (file) => {
// Step 1: Get signature
const sigResponse = await fetch('https://dashboard.linkscale.to/api/v1/assets', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
},
body: JSON.stringify({ expiration_minutes: 10 })
});
const { upload_config } = await sigResponse.json();
// Step 2: Upload to Uploadcare
const formData = new FormData();
formData.append('UPLOADCARE_PUB_KEY', upload_config.public_key);
formData.append('UPLOADCARE_STORE', 'auto');
formData.append('signature', upload_config.signature);
formData.append('expire', upload_config.expire.toString());
formData.append('file', file);
formData.append('metadata[project_id]', upload_config.metadata.project_id);
formData.append('metadata[api_key_id]', upload_config.metadata.api_key_id);
const uploadResponse = await fetch(upload_config.upload_url, {
method: 'POST',
body: formData
});
const { file: fileId } = await uploadResponse.json();
console.log('📤 File uploaded, ID:', fileId);
// Step 3: Poll for validation (THIS ENDPOINT)
const asset = await pollValidation(fileId);
console.log('✅ Upload complete!');
console.log('CDN URL:', asset.asset.provider_file_url);
return asset;
};
| file_id required | string Example: 17be4678-dab7-4bc7-8753-28914a22960a Uploadcare UUID of the file (received after uploading to Uploadcare) |
{- "asset": {
- "_id": "67890xyz",
- "project_id": "proj_abc123",
- "file_id": "17be4678-dab7-4bc7-8753-28914a22960a",
- "provider_file_name": "logo.png",
- "file_mime_type": "image/png",
- "provider_date_time_uploaded": "2025-10-09T10:30:00.000Z",
- "file_size": 123456,
- "image_info": {
- "width": 1920,
- "height": 1080,
- "format": "PNG",
- "color_mode": "RGB",
- "dpi": [
- 72,
- 72
]
}, - "created_at": "2025-10-09T10:30:05.000Z"
}, - "project": {
- "project_id": "proj_abc123",
- "project_name": "My Project"
}
}Retrieve raw visit or click logs for the project associated with the API key. All IP addresses are anonymized for privacy.
Authorization: Bearer API key
Required permission: logs.read_project
| source | string Default: "visits" Enum: "visits" "clicks" Example: source=visits Type of logs to retrieve: |
| from | string <date-time> Example: from=2026-03-01T00:00:00.000Z Start date (ISO 8601 format, e.g. |
| to | string <date-time> Example: to=2026-03-31T23:59:59.999Z End date (ISO 8601 format) |
| limit | integer [ 1 .. 100 ] Default: 30 Example: limit=30 Number of results per page (1–100) |
| last_timestamp | string <date-time> Example: last_timestamp=2026-03-30T14:22:01.000Z Cursor for pagination. Pass the |
| country | string Example: country=FR Filter by 2-letter country code (e.g. |
| visitor_type | string Default: "all" Enum: "all" "humans" "bots" Example: visitor_type=humans Filter by visitor type: |
{- "success": true,
- "data": [
- {
- "_id": "abc123",
- "timestamp": "2026-03-30T14:22:01.000Z",
- "country": "FR",
- "city": "Paris",
- "u": "my-link",
- "bot": 0,
- "host": "linkscale.to",
- "id": "665a1f2e3b4c5d6e7f8a9b0c",
- "userAgent": "Mozilla/5.0 ...",
- "device_type": "mobile",
- "vpn": 0,
- "spam": 0,
- "clicks": [
- {
- "btn_id": "cta-1",
- "position": 0,
- "created_at": "2026-03-30T14:22:03.000Z"
}
]
}
]
}Retrieve raw visit or click logs for all links inside a specific folder. All IP addresses are anonymized for privacy.
Authorization: Bearer API key
Required permission: logs.read_folder
| folder_id required | string Example: 665a1f2e3b4c5d6e7f8a9b0c The unique identifier of the folder |
| source | string Default: "visits" Enum: "visits" "clicks" Example: source=visits Type of logs to retrieve: |
| from | string <date-time> Example: from=2026-03-01T00:00:00.000Z Start date (ISO 8601 format, e.g. |
| to | string <date-time> Example: to=2026-03-31T23:59:59.999Z End date (ISO 8601 format) |
| limit | integer [ 1 .. 100 ] Default: 30 Example: limit=30 Number of results per page (1–100) |
| last_timestamp | string <date-time> Example: last_timestamp=2026-03-30T14:22:01.000Z Cursor for pagination. Pass the |
| country | string Example: country=FR Filter by 2-letter country code (e.g. |
| visitor_type | string Default: "all" Enum: "all" "humans" "bots" Example: visitor_type=humans Filter by visitor type: |
{- "success": true,
- "data": [
- {
- "_id": "abc123",
- "timestamp": "2026-03-30T14:22:01.000Z",
- "country": "FR",
- "city": "Paris",
- "u": "my-link",
- "bot": 0,
- "host": "linkscale.to",
- "id": "665a1f2e3b4c5d6e7f8a9b0c",
- "userAgent": "Mozilla/5.0 ...",
- "device_type": "mobile",
- "vpn": 0,
- "spam": 0,
- "clicks": [
- {
- "btn_id": "cta-1",
- "position": 0,
- "created_at": "2026-03-30T14:22:03.000Z"
}
]
}
]
}Retrieve raw visit or click logs for a specific link. All IP addresses are anonymized for privacy.
Authorization: Bearer API key
Required permission: logs.read_link
| link_id required | string Example: 665a1f2e3b4c5d6e7f8a9b0c The unique identifier of the link |
| source | string Default: "visits" Enum: "visits" "clicks" Example: source=visits Type of logs to retrieve: |
| from | string <date-time> Example: from=2026-03-01T00:00:00.000Z Start date (ISO 8601 format, e.g. |
| to | string <date-time> Example: to=2026-03-31T23:59:59.999Z End date (ISO 8601 format) |
| limit | integer [ 1 .. 100 ] Default: 30 Example: limit=30 Number of results per page (1–100) |
| last_timestamp | string <date-time> Example: last_timestamp=2026-03-30T14:22:01.000Z Cursor for pagination. Pass the |
| country | string Example: country=FR Filter by 2-letter country code (e.g. |
| visitor_type | string Default: "all" Enum: "all" "humans" "bots" Example: visitor_type=humans Filter by visitor type: |
{- "success": true,
- "data": [
- {
- "_id": "abc123",
- "timestamp": "2026-03-30T14:22:01.000Z",
- "country": "FR",
- "city": "Paris",
- "u": "my-link",
- "bot": 0,
- "host": "linkscale.to",
- "id": "665a1f2e3b4c5d6e7f8a9b0c",
- "userAgent": "Mozilla/5.0 ...",
- "device_type": "mobile",
- "vpn": 0,
- "spam": 0,
- "clicks": [
- {
- "btn_id": "cta-1",
- "position": 0,
- "created_at": "2026-03-30T14:22:03.000Z"
}
]
}
]
}Retrieve a simple list of all folders in your project without statistics or analytics.
This is a lightweight endpoint designed for quick folder listing. For detailed analytics and statistics, use /api/v1/folders/stats instead.
Key Features:
folders.read permissionUse this endpoint when you need to:
Use /api/v1/folders/stats when you need:
{- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project": {
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "project_name": "My Project"
}, - "folders": [
- {
- "_id": "folder_abc123",
- "name": "Marketing Campaign",
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "links_count": 15,
- "created_at": "2025-10-19T10:30:00.000Z",
- "updated_at": "2025-10-19T15:45:00.000Z"
}, - {
- "_id": "folder_def456",
- "name": "Social Media",
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "links_count": 23,
- "created_at": "2025-10-18T08:15:00.000Z",
- "updated_at": "2025-10-19T12:30:00.000Z"
}, - {
- "_id": "folder_ghi789",
- "name": "Product Launch",
- "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
- "links_count": 8,
- "created_at": "2025-10-15T14:20:00.000Z",
- "updated_at": "2025-10-16T09:00:00.000Z"
}
]
}Get comprehensive statistics for your entire project. Supports optional timezone and lazy-loading of traffic data via traffic_data_type.
When include_clicks=true, button clicks data is merged into each trafficByUrls item as a button_clicks array.
| from | string <date-time> Start date (ISO 8601) |
| to | string <date-time> End date (ISO 8601) |
| timezone | string Example: timezone=Europe/Paris Timezone for data aggregation (IANA tz). Default is UTC. |
| traffic_data_type | string Default: "urls" Enum: "urls" "links" "both" "none" Type of traffic data to include |
| include_clicks | boolean Default: false When true, includes detailed button clicks data merged into each |
| exclude_referer | string Example: exclude_referer=["https://t.co/","https://twitter.com"] Array of referers to exclude from statistics (JSON-encoded array in query string). Each referer string must not exceed 500 characters. Example: |
| exclude_useragent | string Example: exclude_useragent=["Googlebot","TelegramBot"] Array of user agents to exclude from statistics (JSON-encoded array in query string). Each user agent string must not exceed 500 characters. Example: |
| exclude_country | string Example: exclude_country=["MX","FR","US"] Array of country codes to exclude from statistics (JSON-encoded array in query string). Each country code must not exceed 10 characters. Use ISO 3166-1 alpha-2 codes (e.g., "MX", "FR", "US"). All clicks from the specified countries will be excluded from the returned statistics. This filter applies to all metrics including summary counts, traffic by countries, top referrers, etc. Example: |
| traffic_type | string Default: "unique_users" Enum: "visits" "unique_users" Example: traffic_type=unique_users Type of traffic counting method to use for statistics.
This parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs. Example: |
{- "summary": {
- "totalViews": 45230,
- "normalTraffic": 42100,
- "botTraffic": 3130,
- "totalCountries": 87,
- "totalReferrers": 234
}, - "dailyTraffic": [
- {
- "date": "2024-01-01T00:00:00.000Z",
- "totalViews": 1250,
- "normalTraffic": 1180,
- "botTraffic": 70
}
], - "topReferrers": [
- {
- "referrer": "google.com",
- "views": 15000
}
], - "socialTraffic": [
- {
- "platform": "Facebook",
- "views": 8500
}
], - "trafficByCountries": [
- {
- "date": "2024-01-01T00:00:00.000Z",
- "countries": [
- {
- "country": "US",
- "views": 450
}
]
}
], - "trafficByUrls": [
- {
- "date": "2025-10-10T00:00:00.000Z",
- "host": "emilycutie.me",
- "u": "92",
- "id": "688fe9a45202a5dd8e25d639",
- "project_id": "dfc5abb5-d7e3-49a5-9535-36f43ea6d4d8",
- "url": "emilycutie.me/92",
- "clicks": 2949,
- "bots": 25,
- "human_proxy": 6,
- "human_vpn": 0,
- "human_proxy_vpn": 229,
- "note": "ismaud75",
- "currentNote": "Campaign Q4 2024",
- "countries": [
- {
- "country": "US",
- "visits": 305
}
], - "button_clicks": [
- {
- "btn_id": "abc123",
- "clicks": 5,
- "lastClick": "2025-12-04 13:13:02.839",
- "position": 0,
- "btn_v": "46"
}
]
}
], - "trafficByLinks": [
- {
- "link_id": "abc123",
- "link_name": "Campaign A",
- "folder_id": "folder1",
- "views": 1200,
- "clicks": 350,
- "note": "Previous note",
- "currentNote": "Updated campaign note"
}
]
}Get statistics for all folders in your project.
| from | string <date-time> Start date (ISO 8601) |
| to | string <date-time> End date (ISO 8601) |
| timezone | string Example: timezone=UTC Timezone for data aggregation (IANA tz). Default is UTC. |
| exclude_referer | string Example: exclude_referer=["https://t.co/","https://twitter.com"] Array of referers to exclude from statistics (JSON-encoded array in query string). Each referer string must not exceed 500 characters. Example: |
| exclude_useragent | string Example: exclude_useragent=["Googlebot","TelegramBot"] Array of user agents to exclude from statistics (JSON-encoded array in query string). Each user agent string must not exceed 500 characters. Example: |
| exclude_country | string Example: exclude_country=["MX","FR","US"] Array of country codes to exclude from statistics (JSON-encoded array in query string). Each country code must not exceed 10 characters. Use ISO 3166-1 alpha-2 codes (e.g., "MX", "FR", "US"). All clicks from the specified countries will be excluded from the returned statistics. This filter applies to all metrics including summary counts, traffic by countries, top referrers, etc. Example: |
| traffic_type | string Default: "unique_users" Enum: "visits" "unique_users" Example: traffic_type=unique_users Type of traffic counting method to use for statistics.
This parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs. Example: |
{- "project_id": "project123",
- "date_range": {
- "from": "2024-01-01T00:00:00Z",
- "to": "2024-01-31T23:59:59Z",
- "timezone": "UTC"
}, - "project": {
- "project_id": "project123",
- "project_name": "My Project"
}, - "stats": {
- "folders": [
- {
- "folder_id": "folder1",
- "folder_name": "Marketing Links",
- "totalClicks": 450,
- "totalViews": 1200,
- "uniqueVisitors": 890,
- "links": [
- {
- "link_id": "link1",
- "link_name": "Campaign A",
- "clicks": 200,
- "views": 500
}
]
}
], - "summary": {
- "totalFolders": 12,
- "totalLinks": 127,
- "totalClicks": 11700,
- "totalViews": 45200
}
}
}Get detailed statistics for a specific folder. Supports optional timezone and lazy-loading of traffic data via traffic_data_type.
When include_clicks=true, button clicks data is merged into each trafficByUrls item as a button_clicks array.
| folder_id required | string Example: folder1 The ID of the folder |
| from | string <date-time> Start date (ISO 8601) |
| to | string <date-time> End date (ISO 8601) |
| timezone | string Example: timezone=UTC Timezone for data aggregation (IANA tz). Default is UTC. |
| traffic_data_type | string Default: "urls" Enum: "urls" "links" "both" "none" Type of traffic data to include in the response. Controls lazy-loading of trafficByUrls and trafficByLinks data. "urls" includes only traffic by URL (default), "links" includes only traffic by links, "both" includes both types, "none" excludes detailed traffic data. |
| include_clicks | boolean Default: false When true, includes detailed button clicks data merged into each |
| exclude_referer | string Example: exclude_referer=["https://t.co/","https://twitter.com"] Array of referers to exclude from statistics (JSON-encoded array in query string). Each referer string must not exceed 500 characters. Example: |
| exclude_useragent | string Example: exclude_useragent=["Googlebot","TelegramBot"] Array of user agents to exclude from statistics (JSON-encoded array in query string). Each user agent string must not exceed 500 characters. Example: |
| exclude_country | string Example: exclude_country=["MX","FR","US"] Array of country codes to exclude from statistics (JSON-encoded array in query string). Each country code must not exceed 10 characters. Use ISO 3166-1 alpha-2 codes (e.g., "MX", "FR", "US"). All clicks from the specified countries will be excluded from the returned statistics. This filter applies to all metrics including summary counts, traffic by countries, top referrers, etc. Example: |
| traffic_type | string Default: "unique_users" Enum: "visits" "unique_users" Example: traffic_type=unique_users Type of traffic counting method to use for statistics.
This parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs. Example: |
{- "folder_id": "folder1",
- "folder_name": "Marketing Links",
- "date_range": {
- "from": "2024-01-01T00:00:00Z",
- "to": "2024-01-31T23:59:59Z",
- "timezone": "UTC"
}, - "analytics": {
- "visits": 1200,
- "bots": 85,
- "total": 1285,
- "countries": 45,
- "topCountries": [
- {
- "country": "US",
- "visits": 450
}
], - "topReferrers": [
- {
- "referrer": "google.com",
- "visits": 380
}
], - "topLinks": [
- {
- "link_id": "link1",
- "link_name": "Campaign A",
- "visits": 500,
- "clicks": 200
}
]
}, - "trafficByUrls": [
- {
- "date": "2025-10-10T00:00:00.000Z",
- "host": "emilycutie.me",
- "u": "92",
- "id": "688fe9a45202a5dd8e25d639",
- "project_id": "dfc5abb5-d7e3-49a5-9535-36f43ea6d4d8",
- "url": "emilycutie.me/92",
- "clicks": 2949,
- "bots": 25,
- "human_proxy": 6,
- "human_vpn": 0,
- "human_proxy_vpn": 229,
- "note": "ismaud75",
- "currentNote": "Campaign Q4 2024",
- "countries": [
- {
- "country": "US",
- "visits": 305
}
], - "button_clicks": [
- {
- "btn_id": "abc123",
- "clicks": 5,
- "lastClick": "2025-12-04 13:13:02.839",
- "position": 0,
- "btn_v": "46"
}
]
}
], - "trafficByLinks": [
- {
- "link_id": "abc123",
- "link_name": "Campaign A",
- "folder_id": "folder1",
- "views": 1200,
- "clicks": 350,
- "note": "Previous note",
- "currentNote": "Updated campaign note"
}
]
}Retrieve analytics data for a specific link within a date range. Includes click aggregates from ClickHouse.
Authorization: Bearer API key
Required permission: statistics.read_link
Backward compatible: Existing fields unchanged; new field analytics.button_clicks added when include_clicks=true.
| link_id required | string Example: 68b5c1a88568a81cc8355a64 The unique identifier of the link |
| from required | string <date-time> Example: from=2025-10-26T00:00:00Z Start date for the analytics period (ISO 8601 format) |
| to required | string <date-time> Example: to=2025-10-27T00:00:00Z End date for the analytics period (ISO 8601 format) |
| timezone | string Example: timezone=UTC Timezone for data aggregation (IANA tz string). Defaults to project timezone or UTC. |
| include_clicks | boolean Default: false When true, includes detailed button clicks data in the |
| exclude_referer | string Example: exclude_referer=["https://t.co/","https://twitter.com"] Array of referers to exclude from statistics (JSON-encoded array in query string). Each referer string must not exceed 500 characters. Example: |
| exclude_useragent | string Example: exclude_useragent=["Googlebot","TelegramBot"] Array of user agents to exclude from statistics (JSON-encoded array in query string). Each user agent string must not exceed 500 characters. Example: |
| exclude_country | string Example: exclude_country=["MX","FR","US"] Array of country codes to exclude from statistics (JSON-encoded array in query string). Each country code must not exceed 10 characters. Use ISO 3166-1 alpha-2 codes (e.g., "MX", "FR", "US"). All clicks from the specified countries will be excluded from the returned statistics. This filter applies to all metrics including summary counts, traffic by countries, top referrers, etc. Example: |
| traffic_type | string Default: "unique_users" Enum: "visits" "unique_users" Example: traffic_type=unique_users Type of traffic counting method to use for statistics.
This parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs. Example: |
{- "link_id": "68b5c1a88568a81cc8355a64",
- "date_range": {
- "from": "2025-10-26T00:00:00Z",
- "to": "2025-10-27T00:00:00Z",
- "timezone": "UTC"
}, - "project": {
- "project_id": "proj_123",
- "project_name": "My Project"
}, - "analytics": {
- "visits": 1243,
- "bots": 91,
- "total": 1334,
- "countries": 27,
- "topCountries": [
- {
- "country": "United States",
- "visits": 410
}, - {
- "country": "FR",
- "visits": 205
}, - {
- "country": "CA",
- "visits": 128
}
], - "topReferrers": [
- {
- "referrer": "direct",
- "visits": 512
},
], - "button_clicks": [
- {
- "btn_id": "abc123",
- "clicks": 5,
- "lastClick": "2025-12-04 13:13:02.839",
- "position": 0,
- "btn_v": "46"
}, - {
- "btn_id": "def456",
- "clicks": 3,
- "lastClick": "2025-12-04 12:45:30.456",
- "position": 1,
- "btn_v": "46"
}, - {
- "btn_id": null,
- "clicks": 2,
- "lastClick": "2025-12-04 10:20:18.789",
- "position": null,
- "btn_v": null
}
]
}
}Compares link traffic over two consecutive time windows (current vs. previous) to detect spikes, rising trends, and declining links within a project.
Returns per-link metrics, user category breakdowns, country data, and a computed spike score used for ranking.
Authorization: Bearer API key
Required permission: view_project_statistics
The API splits recent traffic into two consecutive windows of equal duration (defined by trending_interval). For example, with trending_interval=6h:
Each link is then scored and classified based on the change between windows.
The spike score is a composite metric used to rank links by "trendiness". It balances absolute growth, relative growth, and volume:
spike_score = (absoluteComponent × 0.4) + (relativeComponent × 0.4) + (volumeBonus × 0.2)
| Component | Weight | Formula | Purpose |
|---|---|---|---|
| absoluteComponent | 40% | sqrt(absoluteChange) |
Prevents huge volumes from dominating |
| relativeComponent | 40% | percentChange / 100, capped at 10 |
Prevents tiny-base links from inflating scores |
| volumeBonus | 20% | log2(currentVisits) |
Gives slight edge to higher-volume links |
Applied in order (first match wins):
| Trend | Condition |
|---|---|
new |
previous == 0 AND current > 0 |
spike |
percentChange >= 200% AND current >= 20 |
rising |
percentChange >= 50% |
stable |
percentChange >= -30% |
declining |
percentChange >= -60% |
dropping |
percentChange < -60% |
Categories are mutually exclusive:
| Category | Conditions | Description |
|---|---|---|
normal |
prx=0, vpn=0 | Regular human visitor |
proxy |
prx=1, vpn=0 | Human via proxy only |
vpn |
prx=0, vpn=1 | Human via VPN only |
proxy_vpn |
prx=1, vpn=1 | Human via both proxy and VPN |
bots |
bot=1 | Bot traffic |
spam |
spam=1 | Spam traffic |
current_visits=current_normal + current_proxy + current_vpn + current_proxy_vpn + current_bots + current_spam
| project_id required | string Example: project_id=abc123 The project ID to analyze |
| trending_interval | string Default: "6h" Enum: "3h" "6h" "12h" "24h" Example: trending_interval=6h Comparison window duration. Defines the size of both the current and previous windows.
|
{- "success": true,
- "data": {
- "summary": {
- "total_current_visits": 4520,
- "total_previous_visits": 3100,
- "total_change": 1420,
- "total_change_percent": 45.8,
- "total_links": 87,
- "trend_counts": {
- "spike": 3,
- "rising": 12,
- "stable": 45,
- "declining": 18,
- "dropping": 5,
- "new": 4
}
}, - "links": [
- {
- "id": "link_abc123",
- "host": "example.com",
- "t": "xY7kq",
- "deleted": false,
- "current_visits": 320,
- "previous_visits": 45,
- "current_normal": 280,
- "current_proxy": 15,
- "current_vpn": 20,
- "current_proxy_vpn": 2,
- "current_bots": 3,
- "current_spam": 0,
- "previous_normal": 40,
- "previous_proxy": 2,
- "previous_vpn": 3,
- "previous_proxy_vpn": 0,
- "current_unique_ips": 290,
- "previous_unique_ips": 42,
- "absolute_change": 275,
- "percent_change": 611.1,
- "spike_score": 14.82,
- "trend": "spike",
- "countries": [
- {
- "country": "US",
- "visits": 150,
- "proxy": 5,
- "vpn": 12,
- "proxy_vpn": 1
}, - {
- "country": "FR",
- "visits": 95,
- "proxy": 8,
- "vpn": 3,
- "proxy_vpn": 0
}
]
}
], - "query_time_ms": 142
}
}Returns all social network accounts for the project, with their latest metrics from ClickHouse.
Authorization: Bearer API key
Required permission: social_networks.read
Rate limit: 2 requests per second
Supported platforms: Instagram, Twitter/X, TikTok, YouTube, Reddit, Threads, Telegram, Facebook, Snapchat.
| folder_id | string Example: folder_id=64f1a2b3c4d5e6f7a8b9c0d1 Filter by folder ID |
| include_last_post | boolean Default: false Include the date of the most recent post for each account |
{- "social_networks": [
- {
- "_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "type": "instagram",
- "handle": "example_account",
- "display_name": "Example Account",
- "folders": [
- "64f1a2b3c4d5e6f7a8b9c0d2"
], - "created_at": "2024-01-15T10:00:00.000Z",
- "latest_analysis": {
- "followers": 125000,
- "posts": 342,
- "following": 500,
- "engagement_rate": 3.5,
- "avg_likes": 4500,
- "avg_comments": 120,
- "bio": "Account bio text",
- "verified": true,
- "snapshot_date": "2024-06-01",
- "snapshot_timestamp": "2024-06-01 12:00:00",
- "fetched_at": "2024-06-01 12:05:00"
}, - "last_post_date": "2024-05-30T15:30:00.000Z"
}
]
}Returns a single social network account with its latest metrics and recent history (last 30 snapshots).
Authorization: Bearer API key
Required permission: social_networks.read
| social_id required | string Example: 64f1a2b3c4d5e6f7a8b9c0d1 The unique identifier of the social network account |
{- "social_network": {
- "_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "type": "instagram",
- "handle": "example_account",
- "display_name": "Example Account",
- "folders": [ ],
- "created_at": "2024-01-15T10:00:00.000Z"
}, - "latest_analysis": {
- "followers": 125000,
- "posts": 342,
- "following": 500,
- "engagement_rate": 3.5,
- "avg_likes": 4500,
- "avg_comments": 120,
- "snapshot_date": "2024-06-01",
- "snapshot_timestamp": "2024-06-01 12:00:00"
}, - "recent_history": [
- {
- "followers": 125000,
- "posts": 342,
- "snapshot_date": "2024-06-01",
- "snapshot_timestamp": "2024-06-01 12:00:00"
}, - {
- "followers": 124800,
- "posts": 341,
- "snapshot_date": "2024-05-31",
- "snapshot_timestamp": "2024-05-31 12:00:00"
}
]
}Returns the metrics history (followers, posts, engagement) over time for a social network account.
Authorization: Bearer API key
Required permission: social_networks.read
| social_id required | string Example: 64f1a2b3c4d5e6f7a8b9c0d1 The unique identifier of the social network account |
| from | string <date-time> Example: from=2024-01-01T00:00:00Z Start date (ISO 8601 format) |
| to | string <date-time> Example: to=2024-06-01T00:00:00Z End date (ISO 8601 format) |
| limit | integer [ 1 .. 1000 ] Default: 30 Maximum number of results (1-1000) |
{- "social_network_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "history": [
- {
- "followers": 125000,
- "posts": 342,
- "following": 500,
- "engagement_rate": 3.5,
- "avg_likes": 4500,
- "avg_comments": 120,
- "snapshot_date": "2024-06-01",
- "snapshot_timestamp": "2024-06-01 12:00:00",
- "fetched_at": "2024-06-01 12:05:00"
}
]
}Returns paginated posts for a social network account, enriched with the latest metrics from ClickHouse.
Authorization: Bearer API key
Required permission: social_networks.read
| social_id required | string Example: 64f1a2b3c4d5e6f7a8b9c0d1 The unique identifier of the social network account |
| limit | integer [ 1 .. 100 ] Default: 50 Items per page (1-100) |
| offset | integer >= 0 Default: 0 Number of items to skip |
{- "posts": [
- {
- "_id": "65a1b2c3d4e5f6a7b8c9d0e1",
- "social_network_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "shortcode": "CxY1234567",
- "text": "Post caption here...",
- "posted_at": "2024-05-28T15:00:00.000Z",
- "created_at": "2024-05-28T16:00:00.000Z",
- "latest_analysis": {
- "metrics": {
- "likes": 5200,
- "comments": 142,
- "shares": 89,
- "views": 45000,
- "saves": 320,
- "engagement_total": 5753,
- "engagement_rate": 4.6,
- "snapshot_date": "2024-06-01"
}
}, - "links": [ ]
}
], - "total": 342,
- "limit": 20,
- "offset": 0
}Returns full detail for a single post including latest metrics, metrics history, social network info, and connected links.
Authorization: Bearer API key
Required permission: social_networks.read
| social_id required | string Example: 64f1a2b3c4d5e6f7a8b9c0d1 The unique identifier of the social network account |
| post_id required | string Example: 65a1b2c3d4e5f6a7b8c9d0e1 The unique identifier of the post |
{- "post": {
- "_id": "65a1b2c3d4e5f6a7b8c9d0e1",
- "social_network_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "shortcode": "CxY1234567",
- "text": "Post caption...",
- "media": [ ],
- "posted_at": "2024-05-28T15:00:00.000Z"
}, - "latest_analysis": {
- "likes": 5200,
- "comments": 142,
- "views": 45000,
- "engagement_total": 5753
}, - "analysis_history": [
- {
- "likes": 5200,
- "comments": 142,
- "snapshot_date": "2024-06-01"
}, - {
- "likes": 4800,
- "comments": 130,
- "snapshot_date": "2024-05-31"
}
], - "social_network": {
- "_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "handle": "example_account",
- "type": "instagram"
}, - "links": [ ]
}Returns the metrics history over time for a specific post.
Authorization: Bearer API key
Required permission: social_networks.read
| social_id required | string Example: 64f1a2b3c4d5e6f7a8b9c0d1 The unique identifier of the social network account |
| post_id required | string Example: 65a1b2c3d4e5f6a7b8c9d0e1 The unique identifier of the post |
| from | string <date-time> Start date (ISO 8601 format) |
| to | string <date-time> End date (ISO 8601 format) |
| limit | integer [ 1 .. 1000 ] Default: 30 Maximum number of results (1-1000) |
{- "post_id": "65a1b2c3d4e5f6a7b8c9d0e1",
- "platform_post_id": "CxY1234567",
- "history": [
- {
- "likes": 5200,
- "comments": 142,
- "shares": 89,
- "views": 45000,
- "saves": 320,
- "engagement_total": 5753,
- "engagement_rate": 4.6,
- "snapshot_date": "2024-06-01",
- "snapshot_timestamp": "2024-06-01 12:00:00",
- "fetched_at": "2024-06-01 12:05:00"
}
]
}Returns project-level aggregated analytics: total followers, growth, daily breakdowns, and per-account evolution.
Authorization: Bearer API key
Required permission: social_networks.read
| days | integer [ 1 .. 90 ] Default: 30 Lookback period in days (1-90) |
{- "accounts_summary": [
- {
- "_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "type": "instagram",
- "handle": "example_account",
- "display_name": "Example Account",
- "followers": 125000,
- "posts": 342,
- "followers_growth": 1200,
- "created_at": "2024-01-15T10:00:00.000Z"
}
], - "accounts_followers_evolution": [
- {
- "_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "type": "instagram",
- "handle": "example_account",
- "display_name": "Example Account",
- "current_followers": 125000,
- "period_growth": 1200,
- "daily_history": [
- {
- "date": "2024-05-01",
- "followers": 123800,
- "growth": 0
}, - {
- "date": "2024-05-02",
- "followers": 123900,
- "growth": 100
}
]
}
], - "daily_followers_growth": [
- {
- "date": "2024-05-01",
- "total_followers": 250000,
- "growth": 0,
- "accounts_count": 5
}, - {
- "date": "2024-05-02",
- "total_followers": 250500,
- "growth": 500,
- "accounts_count": 5
}
], - "daily_posts": [
- {
- "date": "2024-05-01",
- "total_posts": 680,
- "accounts_count": 5
}
], - "total_followers": 500000,
- "total_posts": 1500,
- "total_growth": 5000,
- "total_accounts": 5
}Returns posts showing significant engagement growth over a recent period. Posts must have at least 3 analysis snapshots to qualify.
Authorization: Bearer API key
Required permission: social_networks.read
| days | integer [ 1 .. 90 ] Default: 7 Lookback period in days (1-90) |
| limit | integer [ 1 .. 100 ] Default: 20 Maximum number of results (1-100) |
| social_network_id | string Example: social_network_id=64f1a2b3c4d5e6f7a8b9c0d1 Filter by a specific social network account ID |
[- {
- "_id": "65a1b2c3d4e5f6a7b8c9d0e1",
- "post_id": "CxY1234567",
- "social_network_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "text": "This post went viral...",
- "posted_at": "2024-05-28T15:00:00.000Z",
- "social_network": {
- "_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "handle": "example_account",
- "type": "instagram"
}, - "trending": {
- "analysis_count": 8,
- "initial_engagement": 1200,
- "current_engagement": 5753,
- "engagement_growth": 4553,
- "engagement_growth_pct": 379.4,
- "current_likes": 5200,
- "current_comments": 142,
- "current_views": 45000,
- "current_shares": 89,
- "current_engagement_rate": 4.6
}, - "history": [
- {
- "date": "2024-05-29",
- "engagement": 1200,
- "likes": 1000,
- "comments": 50,
- "views": 10000,
- "shares": 20
}, - {
- "date": "2024-05-30",
- "engagement": 3500,
- "likes": 3000,
- "comments": 100,
- "views": 30000,
- "shares": 50
}
]
}
]Returns all social network folders for the project.
Authorization: Bearer API key
Required permission: social_networks.read
{- "folders": [
- {
- "_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "name": "Instagram Accounts",
- "project_id": "proj_abc123",
- "social_count": 3,
- "created_at": "2024-01-15T10:00:00.000Z"
}
]
}Returns aggregated stats for all social networks within a folder, broken down by platform.
Authorization: Bearer API key
Required permission: social_networks.read
| folder_id required | string Example: 64f1a2b3c4d5e6f7a8b9c0d1 The unique identifier of the folder |
{- "project_id": "proj_abc123",
- "folder_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "count": 3,
- "totals": {
- "followers": 350000,
- "posts": 900,
- "likes": 0,
- "views": 0,
- "comments": 0
}, - "by_platform": [
- {
- "platform": "instagram",
- "count": 2,
- "followers": 250000,
- "posts": 600,
- "likes": 0,
- "views": 0,
- "comments": 0
}, - {
- "platform": "tiktok",
- "count": 1,
- "followers": 100000,
- "posts": 300,
- "likes": 0,
- "views": 0,
- "comments": 0
}
], - "socials": [
- {
- "_id": "64f1a2b3c4d5e6f7a8b9c0d1",
- "platform": "instagram",
- "username": "example_account",
- "followers": 125000,
- "posts": 342,
- "likes": 0,
- "views": 0,
- "comments": 0,
- "last_fetched_at": "2024-06-01T12:05:00.000Z"
}
], - "last_refresh": "2024-06-01T12:05:00.000Z"
}