Skip to main content

LinkScale API (1.8.0)

Download OpenAPI specification:Download

LinkScale Support: contact@linkscale.to URL: https://linkscale.to/support License: MIT

Powerful link management API for creating, managing, and tracking shortened links.

Authentication

All API requests require authentication using an API key. Include your API key in the Authorization header:

Authorization: Bearer your_api_key_here

Changelog

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.

Versioning

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.


2026-08-01 — 1.8.0

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.

2026-07-24

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.

2026-03-31

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.

2026-02-19

Added — Social networks & trending links Connected-account analytics, post metrics and history under /api/v1/social-networks, plus GET /api/v1/trending-links.

2025-10-25

Added — Folders /api/v1/folders and folder-scoped statistics.

2025-10-17

Initial public API — links, templates, assets and statistics.


Keeping this list current (internal note). This changelog lives in the info.description of static/openapi.yaml and 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 bump info.version plus the number quoted under Versioning above.

File Upload System

LinkScale uses a secure, three-step upload process with Uploadcare CDN for optimal performance and security.

Quick Start Guide

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

Why This Approach?

  • Security: Time-limited signatures prevent unauthorized uploads
  • Performance: Direct CDN upload, no server bottleneck
  • Scalability: Files never transit through your server
  • Reliability: Automatic validation and metadata extraction
  • Flexibility: Support for images, videos, documents, and more

Supported File Types

  • Images: PNG, JPEG, GIF, WebP, SVG (with dimensions, format, DPI)
  • Videos: MP4, WebM, MOV (with duration, bitrate, codecs)
  • Audio: MP3, WAV, OGG, M4A (with duration, bitrate)
  • Documents: PDF, JSON, XML, TXT, CSV

Complete Implementation

See the detailed documentation in the Assets endpoints for complete code examples in JavaScript/Node.js.

Dynamic Features

LinkScale provides powerful dynamic features that allow you to reuse templates and configurations while customizing specific elements per link.

Dynamic Informations

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:

  • Override template name with custom display name
  • Override template profile picture with custom image
  • Granular control over profile picture styling (size, borders, etc.)
  • Master toggles to enable/disable overrides
  • Only works when cs_template is specified

Example:

{
  "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

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).

The model in one paragraph

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.

Anatomy of a rule

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.

Only one rule wins

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.

Things that will surprise you

  • 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 [].
  • A matched geo rule suppresses A/B test flows for that visitor. Geo targeting and A/B testing on the same link do not compose — geo wins.
  • Geo rules change how the link is cached. A link with rules is cached per visitor cohort instead of shared, which is correct but means slightly less edge-cache reuse.
  • 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.
  • Rules that could never work are rejected, rather than silently stored. An 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.

Worked example

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.

Appending a rule safely

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' }]
  })
});

API Logs

How the /api/v1/.../logs endpoints work, why they scale, and the caveats you should know before promising things to API consumers.


TL;DR — How the system works (for 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.

How a consumer integrates — 30 seconds

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);

What the consumer must know

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)

HTTP status codes

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.)

The auth flow under the hood

  1. Consumer sends Authorization: Bearer lk_xxxxx.
  2. Server SHA-256-hashes the key and looks it up in projects_api_keys (Mongo). Raw keys are never stored.
  3. The matched key's permissions object (e.g. { logs: { read_link: true } }) is loaded onto the request.
  4. Per-scope permission is checked (logs.read_project for /logs, logs.read_link for /links/:id/logs, etc.). Scope-strict — read_project does not grant read_link.
  5. The query is restricted to project_id = <caller's project> at the SQL level. A consumer cannot read another tenant's data even by guessing IDs.
  6. Every request is recorded in projects_api_logs for audit.

Endpoints

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).


Authentication & rate limiting

  • Authorization: Bearer lk_xxxxx (validated against api_keys in MongoDB).
  • Per-scope permission required: logs.read_project, logs.read_folder, logs.read_link.
  • Rate limit: 2 requests / second / API key (enforced in handleApiKeyRoute.js).
  • All requests are written to 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.


Query parameters

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.

Response envelope

{
  "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).

Row shape (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.

IP masking

  • Raw IPs are stored in ClickHouse but never leave the server.
  • IPv4: 12.34.56.7812.**.**.78
  • IPv6: 2a01:cb00:1234:5678:9abc:def0:1234:56782a01:cb00:****:****:****:****:****:5678
  • IPs embedded in userAgent / referer strings are also masked by regex replacement.

Pagination — how to walk

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.


Why this is fast (the ClickHouse side)

stats table:

  • Sort key: (project_id, timestamp, link_id, user_id)
  • Partitioned monthly on timestamp
  • Bloom-filter index on link_id and mongo_id

clicks_stats table:

  • Sort key: (project_id, created_at, link_id, mongo_id)
  • Bloom-filter index on stats_id and link_id
  • ReplacingMergeTree

The cursor query

SELECTFROM 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

SELECTFROM 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.

Bounds, in plain numbers

For a single page (limit=100):

  • 1 ClickHouse scan over ≤2 monthly partitions of stats, returning ≤100 rows.
  • 1 ClickHouse scan over clicks_stats with a bloom-filtered stats_id IN (…), returning ≤5,000 rows (100 × 50).
  • Network: ~few hundred KB at most.
  • Wall time: typically tens of ms; worst-case low hundreds.

ClickHouse is sized for this. The 30-day fence + sort-key prefix is what keeps the first query bounded.


What's solid

Auth & authorization

  • API key delivered as 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).
  • Stored as sha256(api_key) in projects_api_keys (apiKeyAuth.js). Plaintext keys never logged, even in dev mode.
  • Auth aggregation requires is_active: true, plus successful $lookup joins to both users and projects (preserveNullAndEmptyArrays: false). A deleted user or project = 401.
  • Permissions are loaded into 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.
  • Cross-tenant isolation: 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.

SQL safety

  • Every user-supplied string is wrapped in 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.
  • Joi validates types and lengths upstream of esc():
    • countrystring().max(10)
    • last_timestampstring().isoDate().max(64) (so the cursor can't be a 1MB string and can't be malformed datetime → returns 400, not 500)
    • limitinteger().min(1).max(100)
    • source / visitor_type — strict enum
    • from / to — ISO 8601, plus a 31-day window cap from withDateRangeLimit
  • link_id and folder_id are validated with ObjectId.isValid before they touch SQL.
  • Audit log writes (projects_api_logs) use the validated query object, so a malicious last_timestamp can't bloat Mongo storage.

Performance bounds

  • Sort keys and bloom indexes line up with every WHERE clause the controller emits — no full scans on a healthy table.
  • 30-day fence is unconditional (see Caveats §1).
  • Clicks-per-visit cap of 50 (ROW_NUMBER window) prevents one hot visit from blowing up the response.
  • One batched query for visits, one batched query for their clicks. Never N+1.
  • Rate limit (2 rps/key) gives ClickHouse natural backpressure — burst is bounded.

Data privacy

  • Raw IPs never leave the server. Masked at serialization (maskIp for direct-IP fields, maskIpAddresses for IPs embedded in userAgent/referer).
  • Masking covers IPv4 (12.**.**.78), IPv6 (2a01:cb00:****:…:5678), and click-level ip fields when present.

Pagination correctness

  • Cursor works on both source=visits (cursor column timestamp) and source=clicks (cursor column created_at, aliased back to timestamp in the response).
  • Country/bot filters are correctly skipped for source=clicks because those columns don't exist on clicks_stats (instead of erroring).

Known caveats — read these before promising anything

1. 30-day hard ceiling

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.

2. country / visitor_type only work on source=visits

Those 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.

3. Cursor tie-breaking

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.

4. has_more=true boundary false-positive

If 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.

5. Click cap per visit

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.

6. Rate limit is a soft cap

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.

7. Permissions snapshot at auth time

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.

8. Folder existence is not asserted

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.


What to do if it ever stops being fast

  1. Run scripts/clickhouse/diagnostics/diagnose_clicks_stats_schema.js — confirms sort key + indexes are still in place.
  2. Check 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).
  3. Verify the 30-day fence is still emitted (grep "INTERVAL 30 DAY" src/lib/helpers/stats/clickhouseLogs.js). Removing it is the most common cause of "why did logs get slow".

Audit log (defects found + fixed during the hardening pass)

# 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 — traffic filtering & cloaking

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.

The 30-second version

# 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.

The three surfaces

You want to... Use
Ship a standard protection profile presetinstagram, bots_only, hard_404
Flip one traffic category bucketsbot_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.

What a blocked visitor sees

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.

Presets

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

Build against the contract, not against this table

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".

Demos & Examples

Looking for practical examples and ready-to-use scripts? Visit our GitHub organization for concrete implementations:

🔗 LinkScale GitHub - Code Examples

You'll find:

  • Complete upload workflows with Node.js implementations
  • Link management scripts for batch operations
  • Integration examples for common use cases
  • Real-world scenarios and best practices

These repositories provide production-ready code you can use as a foundation for your own implementations.

Get link details

Retrieve detailed information about a specific link

Authorizations:
BearerAuth
path Parameters
id
required
string

The unique identifier of the link

Responses

Response samples

Content type
application/json
{
  • "link": {
    },
  • "shield": {
    },
  • "project": {
    }
}

Templates

Create a new template

Create a new template for the authenticated project with customization options

Authorizations:
BearerAuth
Request Body schema: application/json
required
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)

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "success": true,
  • "message": "Template created successfully",
  • "data": {
    }
}

Get all templates

Retrieve all templates for the authenticated project

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": [
    ]
}

Get template by ID

Retrieve a specific template by ID from the authenticated project

Authorizations:
BearerAuth
path Parameters
template_id
required
string
Example: 507f1f77bcf86cd799439011

The unique identifier of the template

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Update template

Update a specific template from the authenticated project. Only provided fields will be updated.

Authorizations:
BearerAuth
path Parameters
template_id
required
string
Example: 507f1f77bcf86cd799439011

The unique identifier of the template

Request Body schema: application/json
required
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

Responses

Request samples

Content type
application/json
Example
{
  • "t_name": "New Template Name"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "message": "Template updated successfully",
  • "data": {
    }
}

Delete template

Permanently delete a template from the authenticated project. This action cannot be undone.

Authorizations:
BearerAuth
path Parameters
template_id
required
string
Example: 507f1f77bcf86cd799439011

The unique identifier of the template

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "message": "Template deleted successfully",
  • "data": {
    }
}

Landing Pages

Read a link's landing page

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff

The unique identifier of the link.

query Parameters
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.

Responses

Response samples

Content type
application/json
{
  • "landing": {
    },
  • "customization_model": "page",
  • "dynamic": {
    },
  • "preview_url": "https://your-domain.com/ana",
  • "engine": {
    },
  • "project": {
    }
}

Replace a link's landing page

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff

The unique identifier of the link.

Request Body schema: application/json
required
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.

id
string

Stable page id.

version
integer

Always 2 for Landing v2.

object
object
Array of objects

Ordered top-level sections. Each section is { id, type, layout?, background?, style?, slots?, children? }. See GET /api/v1/landing-engine for every type's layouts, slots, and style keys.

Responses

Request samples

Content type
application/json
{
  • "page": {
    }
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "landing": {
    },
  • "preview_url": "https://your-domain.com/ana",
  • "project": {
    }
}

Patch a link's landing page

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).

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff

The unique identifier of the link.

Request Body schema: application/json
required
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.

id
string

Stable page id.

version
integer

Always 2 for Landing v2.

object
object
Array of objects

Ordered top-level sections. Each section is { id, type, layout?, background?, style?, slots?, children? }. See GET /api/v1/landing-engine for every type's layouts, slots, and style keys.

Responses

Request samples

Content type
application/json
{
  • "page": {
    }
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "landing": {
    },
  • "preview_url": "https://your-domain.com/ana",
  • "project": {
    }
}

List a link's landing version history

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff

The unique identifier of the link.

query Parameters
include_pages
boolean
Default: true

Set to false to omit each version's page (metadata only).

Responses

Response samples

Content type
application/json
{
  • "versions": [
    ],
  • "count": 3,
  • "project": {
    }
}

Read one landing version

Return a single history snapshot with its full, un-folded page.

Authorizations:
BearerAuth
path Parameters
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).

Responses

Response samples

Content type
application/json
{
  • "version": {
    },
  • "project": {
    }
}

Read a link's dynamic overrides (Model B)

Read the Model B per-link override: the shared-template reference (cs_template) plus the per-link dynamic_informations (name / photo) and dynamic_links.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff

The unique identifier of the link.

Responses

Response samples

Content type
application/json
{
  • "ok": true,
  • "dynamic_overrides": {
    },
  • "customization_model": "template_dynamic",
  • "preview_url": "https://your-domain.com/ana",
  • "project": {
    }
}

Replace a link's dynamic overrides (Model B)

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff

The unique identifier of the link.

Request Body schema: application/json
required
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).

Responses

Request samples

Content type
application/json
Example
{}

Response samples

Content type
application/json
{
  • "ok": true,
  • "dynamic_overrides": {
    },
  • "customization_model": "template_dynamic",
  • "preview_url": "https://your-domain.com/ana",
  • "project": {
    }
}

Merge a link's dynamic overrides (Model B)

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff

The unique identifier of the link.

Request Body schema: application/json
required
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).

Responses

Request samples

Content type
application/json
Example
{
  • "dynamic_informations": {
    }
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "dynamic_overrides": {
    },
  • "customization_model": "template_dynamic",
  • "preview_url": "https://your-domain.com/ana",
  • "project": {
    }
}

Read a template's landing page

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).

Authorizations:
BearerAuth
path Parameters
template_id
required
string
Example: 6650b2aa11cc22dd33ee44ff

The unique identifier of the template.

query Parameters
state
string
Default: "published"
Enum: "published" "draft"

Responses

Response samples

Content type
application/json
{
  • "landing": {
    },
  • "customization_model": "page",
  • "dynamic": {
    },
  • "preview_url": "https://your-domain.com/ana",
  • "engine": {
    },
  • "project": {
    }
}

Replace a template's landing page

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).

Authorizations:
BearerAuth
path Parameters
template_id
required
string
Example: 6650b2aa11cc22dd33ee44ff

The unique identifier of the template.

Request Body schema: application/json
required
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.

id
string

Stable page id.

version
integer

Always 2 for Landing v2.

object
object
Array of objects

Ordered top-level sections. Each section is { id, type, layout?, background?, style?, slots?, children? }. See GET /api/v1/landing-engine for every type's layouts, slots, and style keys.

Responses

Request samples

Content type
application/json
{
  • "page": {
    }
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "landing": {
    },
  • "preview_url": "https://your-domain.com/ana",
  • "project": {
    }
}

Patch a template's landing page

Targeted merge (same semantics as the link PATCH). Returns 409 if the template has no Landing v2 page yet.

Authorizations:
BearerAuth
path Parameters
template_id
required
string
Example: 6650b2aa11cc22dd33ee44ff

The unique identifier of the template.

Request Body schema: application/json
required
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.

id
string

Stable page id.

version
integer

Always 2 for Landing v2.

object
object
Array of objects

Ordered top-level sections. Each section is { id, type, layout?, background?, style?, slots?, children? }. See GET /api/v1/landing-engine for every type's layouts, slots, and style keys.

Responses

Request samples

Content type
application/json
{
  • "page": {
    }
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "landing": {
    },
  • "preview_url": "https://your-domain.com/ana",
  • "project": {
    }
}

List a template's landing version history

Snapshots newest first (rolling window of the last 60). ?include_pages=false for a metadata-only list.

Authorizations:
BearerAuth
path Parameters
template_id
required
string
Example: 6650b2aa11cc22dd33ee44ff

The unique identifier of the template.

query Parameters
include_pages
boolean
Default: true

Responses

Response samples

Content type
application/json
{
  • "versions": [
    ],
  • "count": 3,
  • "project": {
    }
}

Read one template landing version

Return a single history snapshot with its full page.

Authorizations:
BearerAuth
path Parameters
template_id
required
string
Example: 6650b2aa11cc22dd33ee44ff

The unique identifier of the template.

version_id
required
string
Example: 6650c3aa11cc22dd33ee44ff

The snapshot id.

Responses

Response samples

Content type
application/json
{
  • "version": {
    },
  • "project": {
    }
}

Get the render contract ("the engine")

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.

Authorizations:
BearerAuth
query Parameters
section
string
Example: section=hero

Filter the catalog to a single section type (e.g. hero). An unknown type returns 404.

Responses

Response samples

Content type
application/json
{
  • "engine": {
    },
  • "project": {
    }
}

Get an example page

A ready-to-PUT example page (hero + links_list) to bootstrap integrators. Cacheable for 5 minutes.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "example": {
    },
  • "project": {
    }
}

Shield

Shield contract (condition vocabulary, actions, presets, bots)

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.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "version": "2.0.0",
  • "model": {
    },
  • "actions": [
    ],
  • "block_screens": {
    },
  • "condition_types": [
    ],
  • "buckets": {
    },
  • "presets": [
    ],
  • "bots": {
    }
}

List the Shield presets

The one-click protection profiles you can apply with {"preset": "<id>"} on any Shield write, plus the simple buckets each of them configures.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "version": "2.0.0",
  • "presets": [
    ]
}

List the registered crawlers

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.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "description": "string",
  • "version": "2.0.0",
  • "kinds": [
    ],
  • "items": [
    ]
}

Read a link's Shield configuration

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff

The unique identifier of the link.

query Parameters
include_page
boolean
Default: false

Include the decoy page snapshot in the response.

Responses

Response samples

Content type
application/json
{
  • "shield": {
    },
  • "contract": {
    },
  • "link": {
    },
  • "project": {
    }
}

Replace a link's Shield configuration

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff
Request Body schema: application/json
required
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 rules. Keys: bot_known, bot_unknown, net_proxy, net_vpn, net_datacenter, or bot:<botId> to pin one crawler. Values are either the action string (off / block / allow) or an object.

object (ShieldBlockScreen)

What a blocked visitor is served. Used both as the link's DEFAULT screen (shield.block, which also accepts do_nothing) and as a per-rule override (rule.action.block, where do_nothing is not allowed — a per-rule "let through" is the allow action). A rule with no screen of its own inherits the link default.

Responses

Request samples

Content type
application/json
Example
{
  • "enabled": true,
  • "preset": "instagram"
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "shield": {
    },
  • "updated_fields": [
    ],
  • "contract": {
    },
  • "link": {
    },
  • "project": {
    },
  • "message": "Shield configuration saved"
}

Update part of a link's Shield configuration

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff
Request Body schema: application/json
required
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 rules. Keys: bot_known, bot_unknown, net_proxy, net_vpn, net_datacenter, or bot:<botId> to pin one crawler. Values are either the action string (off / block / allow) or an object.

object (ShieldBlockScreen)

What a blocked visitor is served. Used both as the link's DEFAULT screen (shield.block, which also accepts do_nothing) and as a per-rule override (rule.action.block, where do_nothing is not allowed — a per-rule "let through" is the allow action). A rule with no screen of its own inherits the link default.

Responses

Request samples

Content type
application/json
Example
{
  • "buckets": {
    }
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "shield": {
    },
  • "updated_fields": [
    ],
  • "contract": {
    },
  • "link": {
    },
  • "project": {
    },
  • "message": "Shield configuration saved"
}

Disable Shield and clear its configuration

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 6650a1bb22cc33dd44ee55ff

Responses

Response samples

Content type
application/json
{
  • "ok": true,
  • "shield": {
    },
  • "updated_fields": [
    ],
  • "contract": {
    },
  • "link": {
    },
  • "project": {
    },
  • "message": "Shield configuration saved"
}

Assets

Generate upload signature

Generate a secure signature for uploading files directly to Uploadcare CDN.

Complete Upload Workflow

Step 1: Request Upload Signature

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();

Step 2: Upload File to Uploadcare

Use the signature to upload your file directly to Uploadcare using multipart/form-data.

Required FormData Fields:

  • UPLOADCARE_PUB_KEY: Public key from upload_config
  • UPLOADCARE_STORE: Set to 'auto' for automatic storage
  • signature: Secure signature from upload_config
  • expire: Unix timestamp from upload_config
  • file: Your file as Blob/File object
  • metadata[project_id]: Project ID from upload_config.metadata
  • metadata[api_key_id]: API key ID from upload_config.metadata

Supported File Types:

  • Images: PNG, JPEG, GIF, WebP, SVG
  • Videos: MP4, WebM, MOV, AVI
  • Audio: MP3, WAV, OGG, M4A
  • Documents: PDF, ZIP, JSON, XML
  • Text: TXT, CSV, HTML, CSS

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();

Step 3: Poll for Validation

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);

Step 4: Use Your Asset

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/"

Security Features

  • Time-limited signatures: Signatures expire after specified time (1-60 minutes)
  • MIME type restrictions: Optionally restrict allowed file types
  • File size limits: Optionally set maximum file size in bytes
  • Metadata tracking: Automatically tagged with project_id and api_key_id
  • Direct upload: Files never transit through your server
  • Automatic validation: Webhook validates files asynchronously
  • Invalid files deleted: Files failing validation are automatically removed

Common MIME Types

Images:

  • image/png - PNG images
  • image/jpeg - JPEG images
  • image/gif - GIF images
  • image/webp - WebP images
  • image/svg+xml - SVG images

Videos:

  • video/mp4 - MP4 videos
  • video/webm - WebM videos

Documents:

  • application/pdf - PDF documents
  • application/json - JSON files

Text:

  • text/plain - Text files
  • text/csv - CSV files

Expected Timing

  • Signature generation: < 100ms
  • Upload to Uploadcare: Depends on file size and connection
  • Validation (webhook): 1-3 seconds (normal)
  • Recommended polling: Every 2 seconds for max 30 seconds

Error Handling

Signature Request Errors:

  • 400: Invalid expiration_minutes (must be 1-60)
  • 401: Missing or invalid API key

Upload Errors:

  • Check Uploadcare response for upload failures
  • Common: signature expired, file too large, invalid MIME type

Validation Errors:

  • 404: File not yet validated (keep polling)
  • Timeout after 30 seconds: File may still be processing or failed validation
Authorizations:
BearerAuth
Request Body schema: application/json
optional
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:

  • Images: image/png, image/jpeg, image/gif, image/webp, image/svg+xml
  • Videos: video/mp4, video/webm, video/mov
  • Audio: audio/mpeg, audio/wav, audio/ogg
  • Documents: application/pdf, application/json, text/plain
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:

  • Profile pictures: 5 MB (5242880 bytes)
  • Images: 10 MB (10485760 bytes)
  • Videos: 50-100 MB (52428800-104857600 bytes)
  • Documents: 10 MB (10485760 bytes)

Responses

Request samples

Content type
application/json
Example
{
  • "expiration_minutes": 10
}

Response samples

Content type
application/json
Example
{
  • "upload_config": {
    },
  • "project": {
    }
}

List assets

Retrieve a paginated list of all validated assets for your project with optional search and filtering.

Features:

  • Search by filename or MIME type
  • Filter by file category (image, video, audio, document, text)
  • Pagination with limit and offset
  • Returns rich metadata (file size, dimensions, duration, etc.)

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:

  • Display asset galleries
  • File managers
  • Search functionality
  • Asset selection interfaces
Authorizations:
BearerAuth
query Parameters
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

Responses

Response samples

Content type
application/json
Example
{
  • "assets": [
    ],
  • "total": 42,
  • "limit": 50,
  • "offset": 0,
  • "project": {
    }
}

Get asset details & validation polling

Retrieve information about a specific asset by its Uploadcare file ID.

Primary Use: Validation Polling

After uploading a file to Uploadcare, you MUST poll this endpoint to verify the file has been validated by the webhook.

Why Polling is Required

The upload process is asynchronous:

  1. You upload → File goes to Uploadcare CDN
  2. Uploadcare → Sends webhook notification to LinkScale
  3. LinkScale webhook → Validates file, extracts metadata (dimensions, duration, etc.)
  4. Database → Asset saved with all metadata
  5. You poll → Get confirmation that asset is ready

Processing includes:

  • File metadata extraction (size, MIME type, filename)
  • Image analysis (dimensions, format, color mode, DPI)
  • Video analysis (duration, bitrate, codecs)
  • Audio analysis (duration, bitrate, codec)
  • Validation checks
  • Automatic deletion of invalid files

Validation Flow

Upload to Uploadcare → Get file_id → Poll this endpoint → 200 OK → Use asset
                                        ↓
                                      404 = Still processing (wait 2s, retry)

Expected Timing

  • Normal validation: 1-3 seconds
  • Large files: Up to 5-10 seconds
  • Recommended polling: Every 2 seconds
  • Maximum attempts: 10-30 attempts (20-60 seconds total)
  • Give up after: 30 seconds (file likely failed validation)

Complete Polling Implementation

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');
};

Response Data

Once validated (200 OK), the response includes:

  • File metadata: ID, name, MIME type, size
  • CDN URL: provider_file_url for accessing the file
  • Upload info: Original upload timestamp
  • Image metadata (if image): width, height, format, color mode, DPI
  • Video metadata (if video): duration, bitrate, codecs
  • Project info: Which project owns this asset

Error Handling

404 Response: File not yet validated

  • Action: Wait 2 seconds and retry
  • Normal: This is expected during polling
  • Maximum retries: 10-30 attempts recommended

401 Response: Unauthorized

  • Cause: Invalid or missing API key
  • Action: Check your Authorization header

Other errors: Validation failed

  • Action: File may be invalid or corrupted

Complete Upload + Polling Example

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;
};
Authorizations:
BearerAuth
path Parameters
file_id
required
string
Example: 17be4678-dab7-4bc7-8753-28914a22960a

Uploadcare UUID of the file (received after uploading to Uploadcare)

Responses

Response samples

Content type
application/json
Example
{
  • "asset": {
    },
  • "project": {
    }
}

Logs

Get project logs

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

Authorizations:
BearerAuth
query Parameters
source
string
Default: "visits"
Enum: "visits" "clicks"
Example: source=visits

Type of logs to retrieve: visits or clicks

from
string <date-time>
Example: from=2026-03-01T00:00:00.000Z

Start date (ISO 8601 format, e.g. 2026-01-01T00:00:00.000Z)

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 timestamp value of the last item from the previous page to fetch the next page.

country
string
Example: country=FR

Filter by 2-letter country code (e.g. FR, US)

visitor_type
string
Default: "all"
Enum: "all" "humans" "bots"
Example: visitor_type=humans

Filter by visitor type: all, humans, or bots

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": [
    ]
}

Get folder logs

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

Authorizations:
BearerAuth
path Parameters
folder_id
required
string
Example: 665a1f2e3b4c5d6e7f8a9b0c

The unique identifier of the folder

query Parameters
source
string
Default: "visits"
Enum: "visits" "clicks"
Example: source=visits

Type of logs to retrieve: visits or clicks

from
string <date-time>
Example: from=2026-03-01T00:00:00.000Z

Start date (ISO 8601 format, e.g. 2026-01-01T00:00:00.000Z)

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 timestamp value of the last item from the previous page to fetch the next page.

country
string
Example: country=FR

Filter by 2-letter country code (e.g. FR, US)

visitor_type
string
Default: "all"
Enum: "all" "humans" "bots"
Example: visitor_type=humans

Filter by visitor type: all, humans, or bots

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": [
    ]
}

Get link logs

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

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 665a1f2e3b4c5d6e7f8a9b0c

The unique identifier of the link

query Parameters
source
string
Default: "visits"
Enum: "visits" "clicks"
Example: source=visits

Type of logs to retrieve: visits or clicks

from
string <date-time>
Example: from=2026-03-01T00:00:00.000Z

Start date (ISO 8601 format, e.g. 2026-01-01T00:00:00.000Z)

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 timestamp value of the last item from the previous page to fetch the next page.

country
string
Example: country=FR

Filter by 2-letter country code (e.g. FR, US)

visitor_type
string
Default: "all"
Enum: "all" "humans" "bots"
Example: visitor_type=humans

Filter by visitor type: all, humans, or bots

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": [
    ]
}

Folders

Get all folders

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:

  • Fast performance (no analytics computation)
  • Returns basic folder information with links count
  • Sorted by creation date (newest first)
  • Requires folders.read permission

Use this endpoint when you need to:

  • Display a folder selector/dropdown
  • List available folders without analytics
  • Get folder metadata quickly

Use /api/v1/folders/stats when you need:

  • Traffic analytics and statistics
  • Date-range based metrics
  • Detailed performance data
Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
Example
{
  • "project_id": "7c78db83-6bdd-4bb4-8545-c7cfdfc1e480",
  • "project": {
    },
  • "folders": [
    ]
}

Statistics

Get project statistics

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.

Authorizations:
BearerAuth
query Parameters
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 trafficByUrls item as button_clicks array.

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_referer=["https://t.co/","https://twitter.com"]

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_useragent=["Googlebot","TelegramBot"]

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: ?exclude_country=["MX","FR"]

traffic_type
string
Default: "unique_users"
Enum: "visits" "unique_users"
Example: traffic_type=unique_users

Type of traffic counting method to use for statistics.

  • unique_users (default): Counts unique users based on IP addresses. Each IP is counted only once per period.
  • visits: Counts all visits individually. Each request is counted separately.

This parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs.

Example: ?traffic_type=visits

Responses

Response samples

Content type
application/json
{
  • "summary": {
    },
  • "dailyTraffic": [
    ],
  • "topReferrers": [
    ],
  • "socialTraffic": [
    ],
  • "trafficByCountries": [
    ],
  • "trafficByUrls": [
    ],
  • "trafficByLinks": [
    ]
}

Get statistics for all folders

Get statistics for all folders in your project.

Authorizations:
BearerAuth
query Parameters
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_referer=["https://t.co/","https://twitter.com"]

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_useragent=["Googlebot","TelegramBot"]

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: ?exclude_country=["MX","FR"]

traffic_type
string
Default: "unique_users"
Enum: "visits" "unique_users"
Example: traffic_type=unique_users

Type of traffic counting method to use for statistics.

  • unique_users (default): Counts unique users based on IP addresses. Each IP is counted only once per period.
  • visits: Counts all visits individually. Each request is counted separately.

This parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs.

Example: ?traffic_type=visits

Responses

Response samples

Content type
application/json
{
  • "project_id": "project123",
  • "date_range": {
    },
  • "project": {
    },
  • "stats": {
    }
}

Get statistics for a specific folder

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.

Authorizations:
BearerAuth
path Parameters
folder_id
required
string
Example: folder1

The ID of the folder

query Parameters
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 trafficByUrls item as button_clicks array.

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_referer=["https://t.co/","https://twitter.com"]

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_useragent=["Googlebot","TelegramBot"]

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: ?exclude_country=["MX","FR"]

traffic_type
string
Default: "unique_users"
Enum: "visits" "unique_users"
Example: traffic_type=unique_users

Type of traffic counting method to use for statistics.

  • unique_users (default): Counts unique users based on IP addresses. Each IP is counted only once per period.
  • visits: Counts all visits individually. Each request is counted separately.

This parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs.

Example: ?traffic_type=visits

Responses

Response samples

Content type
application/json
{
  • "folder_id": "folder1",
  • "folder_name": "Marketing Links",
  • "date_range": {
    },
  • "analytics": {
    },
  • "trafficByUrls": [
    ],
  • "trafficByLinks": [
    ]
}

Get link statistics

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.

Authorizations:
BearerAuth
path Parameters
link_id
required
string
Example: 68b5c1a88568a81cc8355a64

The unique identifier of the link

query Parameters
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 analytics.button_clicks array.

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_referer=["https://t.co/","https://twitter.com"]

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_useragent=["Googlebot","TelegramBot"]

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: ?exclude_country=["MX","FR"]

traffic_type
string
Default: "unique_users"
Enum: "visits" "unique_users"
Example: traffic_type=unique_users

Type of traffic counting method to use for statistics.

  • unique_users (default): Counts unique users based on IP addresses. Each IP is counted only once per period.
  • visits: Counts all visits individually. Each request is counted separately.

This parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs.

Example: ?traffic_type=visits

Responses

Response samples

Content type
application/json
{
  • "link_id": "68b5c1a88568a81cc8355a64",
  • "date_range": {
    },
  • "project": {
    },
  • "analytics": {
    }
}

Social Networks

List social network accounts

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.

Authorizations:
BearerAuth
query Parameters
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

Responses

Response samples

Content type
application/json
{
  • "social_networks": [
    ]
}

Get social network detail

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

Authorizations:
BearerAuth
path Parameters
social_id
required
string
Example: 64f1a2b3c4d5e6f7a8b9c0d1

The unique identifier of the social network account

Responses

Response samples

Content type
application/json
{
  • "social_network": {
    },
  • "latest_analysis": {
    },
  • "recent_history": [
    ]
}

Get account metrics history

Returns the metrics history (followers, posts, engagement) over time for a social network account.

Authorization: Bearer API key

Required permission: social_networks.read

Authorizations:
BearerAuth
path Parameters
social_id
required
string
Example: 64f1a2b3c4d5e6f7a8b9c0d1

The unique identifier of the social network account

query Parameters
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)

Responses

Response samples

Content type
application/json
{
  • "social_network_id": "64f1a2b3c4d5e6f7a8b9c0d1",
  • "history": [
    ]
}

List posts with metrics

Returns paginated posts for a social network account, enriched with the latest metrics from ClickHouse.

Authorization: Bearer API key

Required permission: social_networks.read

Authorizations:
BearerAuth
path Parameters
social_id
required
string
Example: 64f1a2b3c4d5e6f7a8b9c0d1

The unique identifier of the social network account

query Parameters
limit
integer [ 1 .. 100 ]
Default: 50

Items per page (1-100)

offset
integer >= 0
Default: 0

Number of items to skip

Responses

Response samples

Content type
application/json
{
  • "posts": [
    ],
  • "total": 342,
  • "limit": 20,
  • "offset": 0
}

Get post detail

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

Authorizations:
BearerAuth
path Parameters
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

Responses

Response samples

Content type
application/json
{
  • "post": {
    },
  • "latest_analysis": {
    },
  • "analysis_history": [
    ],
  • "social_network": {
    },
  • "links": [ ]
}

Get post metrics history

Returns the metrics history over time for a specific post.

Authorization: Bearer API key

Required permission: social_networks.read

Authorizations:
BearerAuth
path Parameters
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

query Parameters
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)

Responses

Response samples

Content type
application/json
{
  • "post_id": "65a1b2c3d4e5f6a7b8c9d0e1",
  • "platform_post_id": "CxY1234567",
  • "history": [
    ]
}

Get aggregated analytics

Returns project-level aggregated analytics: total followers, growth, daily breakdowns, and per-account evolution.

Authorization: Bearer API key

Required permission: social_networks.read

Authorizations:
BearerAuth
query Parameters
days
integer [ 1 .. 90 ]
Default: 30

Lookback period in days (1-90)

Responses

Response samples

Content type
application/json
{
  • "accounts_summary": [
    ],
  • "accounts_followers_evolution": [
    ],
  • "daily_followers_growth": [
    ],
  • "daily_posts": [
    ],
  • "total_followers": 500000,
  • "total_posts": 1500,
  • "total_growth": 5000,
  • "total_accounts": 5
}

Get trending posts

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

Authorizations:
BearerAuth
query Parameters
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

Responses

Response samples

Content type
application/json
[
  • {
    }
]

List social network folders

Returns all social network folders for the project.

Authorization: Bearer API key

Required permission: social_networks.read

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "folders": [
    ]
}

Get folder stats

Returns aggregated stats for all social networks within a folder, broken down by platform.

Authorization: Bearer API key

Required permission: social_networks.read

Authorizations:
BearerAuth
path Parameters
folder_id
required
string
Example: 64f1a2b3c4d5e6f7a8b9c0d1

The unique identifier of the folder

Responses

Response samples

Content type
application/json
{
  • "project_id": "proj_abc123",
  • "folder_id": "64f1a2b3c4d5e6f7a8b9c0d1",
  • "count": 3,
  • "totals": {
    },
  • "by_platform": [
    ],
  • "socials": [
    ],
  • "last_refresh": "2024-06-01T12:05:00.000Z"
}