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
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.
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 |
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 | |
| shield | boolean Enable shield protection |
| 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. |
{- "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": { }
}, - "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 |
{- "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": { }
}, - "created_at": "2024-01-15T10:30:00Z",
- "updated_at": "2024-01-15T10:30:00Z",
- "expires_at": "2024-12-31T23:59:59Z",
- "is_active": true
}
}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 |
| shield | boolean Enable shield protection |
| 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. |
{
}{- "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"
}
}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"
}