Skip to main content

Developer guide

API concepts

View as Markdown

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

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.

Templates — the three families

A link is not skinned by one template. It has four template slots, backed by three separate families, and each one dresses a different moment of the visitor's journey. They are independent: a link can use all four, or one, or none.

Slot on the linkFamily (?kind=)What it skins
cs_templatelandingThe landing page served for a t: "l_p" link.
cs_1-stepfirst_stepThe 1-step verification gate shown before the landing page.
cs_3dots_templatethree_dotsThe direct "open in browser" escape overlay, shown on arrival.
cs_3dots_click_templatethree_dotsThe click overlay, shown when a visitor taps a link button.

Attaching one, end to end

# 1. Find the template. Ids are unique PER FAMILY, so always pass `kind`.
curl -s "https://dashboard.linkscale.to/api/v1/templates?kind=three_dots&summary=true" \
  -H "Authorization: Bearer lk_xxxxxxxxxxxx"

# 2. Attach it — on create, or on an existing link.
curl -X PATCH https://dashboard.linkscale.to/api/v1/links/<LINK_ID> \
  -H "Authorization: Bearer lk_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"cs_3dots_template": "69df9e86d30eb3d7dff9d2ad"}'

# 3. Read all four slots back.
curl -s https://dashboard.linkscale.to/api/v1/links/<LINK_ID> \
  -H "Authorization: Bearer lk_xxxxxxxxxxxx" | jq .templates

Send null on any slot to detach it. Detaching one slot never touches the others.

Things worth knowing before you build

  • Ids are unique per family, not globally. A first-step id read through GET /api/v1/templates/{id} without ?kind=first_step returns 404 — it is being looked up among the landing templates. Always carry the kind alongside the id.
  • A template must belong to your project. An id from another project is rejected with 400 naming the field, never silently attached.
  • Attaching sets the design, not the behaviour. A 3-dots template does not switch the overlay on — that is deeplinks_logic — and a first-step template does not open the gate, which is 1-step-verification-page.enable. Attach the template to a surface you have already enabled, or nothing changes for the visitor.
  • The template stays the source of truth. All four slots store a reference, resolved at serve time. Editing the template in the dashboard updates every link pointing at it, with no per-link re-save.
  • An inline page beats the landing template. If a link carries its own Page Builder v2 page (see the Landing Pages endpoints), that page is served and cs_template is ignored.
  • Only landing templates can be created through the API. PUT, PATCH and DELETE on /api/v1/templates act on the landing family; first-step and 3-dots templates are authored in the dashboard and read here.

For the common "same design, different person per link" case, do not clone the template. Attach the one cs_template to every link and override just the name and photo per link with dynamic_informations / dynamic_links — described next, and settable in the same call through PUT|PATCH /api/v1/links/{link_id}/dynamic-overrides.

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
}
FieldApplies toMeaning
enabledallDefaults to true. A rule that is not enabled is skipped before anything else is read.
detection_typeallip (default) or browser_language.
locationipOne ISO-3166-1 alpha-2 code, or a group key that expands to many countries.
countriesipISO-3166-1 alpha-2 codes; matches any of them.
regions / citiesipNarrow the match inside the matched country. Raises the rule's priority.
languagebrowser_languageMatched as a substring, so "fr" also catches fr-CA.
tallblock → 404, d_l → redirect to url, l_p → serve a landing page.
urlt: d_lWhere matched visitors are sent. Required for d_l.
cs_templatet: l_pProject template ObjectId, resolved at serve time.
landing_v2_paget: l_pInline 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:

PriorityRule shape
3 (highest)ip plus regions and/or cities
2browser_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

LimitValueWhy
Max history window30 daysClickHouse perf guardrail; older data not retrievable through this endpoint
Page size100 max (default 100)Bounds memory + response size
Rate limit2 req/s per API keyBackpressure on ClickHouse
from/to window31 days maxJoi-enforced, returns 400 if exceeded
Clicks per visit50 max in clicks[]One hot visit can't blow up the response
IP formatalways maskedRaw IPs never leave the server (IPv4: a.**.**.d, IPv6: aaaa:bbbb:****:…:zzzz)

HTTP status codes

CodeWhen
200Success
400Validation error (bad cursor, bad limit, bad date range)
401Missing / malformed / unknown / inactive API key
403API key lacks logs.read_link / logs.read_folder / logs.read_project permission
404link_id / folder_id doesn't belong to the caller's project
429Rate limit (2 rps) exceeded; Retry-After: 1
500Unexpected 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

EndpointScope
GET /api/v1/logsAll visits across the project
GET /api/v1/links/{link_id}/logsOne link's visits
GET /api/v1/folders/{folder_id}/logsAll 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

ParamDefaultNotes
limit100Min 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.
sourcevisitsvisits (one row per visit, with embedded clicks[]) or clicks (one row per click event).
country—ISO country code. Only honored on source=visits.
visitor_typeallhumans / 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.78 → 12.**.**.78
  • IPv6: 2a01:cb00:1234:5678:9abc:def0:1234:5678 → 2a01: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

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.

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():
    • 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 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)

#SeverityDefectFix
1Bug?source=clicks silently ignored last_timestamp — pagination broken on the clicks branchCursor now applies to both sources, using created_at as the column for clicks
2Bug?country= and ?visitor_type= filters were injected into the clicks_stats query, which has no such columns → 500 on those param combosFilters scoped to source=visits only; silently ignored for clicks
3Soft DoSesc() only escaped single quotes; a trailing \ could break out of the string literal and crash the SQL parser as a 500esc() now escapes backslash first, then single quote (both via backslash, ClickHouse-accepted)
4Hardeninglast_timestamp was Joi.string().optional() — accepted any length, any content, only failed at ClickHouse parse timeNow Joi.string().isoDate().max(64).optional() — rejected as 400 with a clear message
5Spec complianceDefault limit was 30, next_cursor / has_more not in response, IP was deleted instead of maskedDefault 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 profilepreset — instagram, bots_only, hard_404
Flip one traffic categorybuckets — bot_known, bot_unknown, net_proxy, net_vpn, net_datacenter, bot:<botId>
Express anything elserules — 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

idWhat it doesBlock screen
instagramBots (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_onlyEvery bot gets the 3-dots overlay; real human proxy / VPN traffic is untouched.3-dots overlay
hard_404Bots, 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".

Shield decides who gets the real page. Privacy decides whether the real destination is written into the page at all.

A private link renders every one of its outbound destinations as a short redirect on your own domain — https://<your-domain>/go/<token> — and resolves it server-side at click time. A crawler, a link scanner or anyone reading the page source sees only the redirect stub; the destination never appears in the HTML.

# Make an existing link private
curl -X PATCH https://dashboard.linkscale.to/api/v1/links/<LINK_ID> \
  -H "Authorization: Bearer lk_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"privacy": {"links": true}}'
ModeWhat a bot reads in the page
Normal (default)https://your-real-destination.com/product/summer-sale
Privatehttps://your-domain.com/go/aX9f2k

What you need to switch on

Only this field. The call that turns Privacy on also provisions every /go/ redirect for the link, and every later edit — through the API or the dashboard — keeps them in sync. There is no separate endpoint, no per-destination registration, and nothing to configure on the domain.

Things worth knowing

  • It covers what the link renders. Link buttons, the CTA, socials, the announcement bar, carousel, chatbot, share and spin-wheel destinations on the landing page, plus the 1-step verification gate. Images, icons and embeds are untouched — they are not visitor destinations.
  • A direct link (type: "d_l") has no page, so there is nothing to hide: it redirects server-side and Privacy does not change what a scanner following it sees. Use Shield for that traffic.
  • The redirect is on your domain, not a shared one, so your links are not exposed to another customer's reputation.
  • Clicks are still tracked. The button click fires before the redirect, and the redirect step is neither a visit nor an extra click — it is invisible to the visitor. In visit logs a cloaked button shows its /go/ URL rather than the destination.
  • Privacy and Shield are independent and combine well: Shield chooses what a suspicious visitor is served, Privacy makes sure that even an allowed page does not spell out where its buttons lead.

Folders are the sections of the dashboard sidebar, and the unit that folder statistics and folder logs aggregate over. Everything the dashboard does with them is reachable from the API: create, rename, delete, and file links in or out.

The one thing to know

Membership lives on the link, not on the folder. There is no "add link to folder" endpoint — you set the link's folder when you create or update the link:

// 1. create the folder (or reuse an id from GET /api/v1/folders)
POST /api/v1/folders
{ "name": "Q3 Campaign" }
// -> { "folder": { "_id": "6a7491caaa4bce8130ea5a62", "links_count": 0, ... } }

// 2. create links straight into it — no manual move afterwards
PUT /api/v1/links
{ "type": "d_l", "u": "promo", "domain": "yourdomain.com",
  "url": "https://example.com", "folder_id": "6a7491caaa4bce8130ea5a62" }

// 3. move an existing link
PATCH /api/v1/links/{link_id}
{ "folder_id": "6a7491caaa4bce8130ea5a62" }

folder_id vs folders

A link may sit in several folders at once, so the underlying field is an array:

FieldUse it for
folder_idThe common single-folder case. Shorthand for a one-element folders array. null takes the link out of every folder.
foldersMulti-folder membership. Replaces the whole list (like geo_rules), so include the ids you want to keep; [] empties it.

Sending both in the same request is a 400 — pick one. A folder id belonging to another project is a 400 as well, so a link can never end up pointing at a folder you cannot see.

Reading it back

  • link.folders — the folder ids, on GET /api/v1/links, GET /api/v1/links/{id} and echoed by both write verbs. Always present; [] for a link outside every folder. This is exactly the shape you send back on a PATCH.
  • folders (sibling of link on GET /api/v1/links/{id}) — the same folders with their names resolved, so rendering a link needs no second call.
  • GET /api/v1/links?folder_id=... — list the links inside one folder.

Deleting a folder

DELETE /api/v1/folders/{folder_id} deletes the folder only. Its links are not deleted: each one is detached and becomes folder-less, and the response reports how many in links_updated. No link is ever left pointing at a folder that no longer exists.

Permissions

Each verb maps to its own API-key permission: folders.read, folders.create, folders.edit, folders.delete. Setting folder_id on a link is a link write — it needs links.create / links.edit, not a folder permission.

A link created with PUT /api/v1/links is the same object as a link created in the dashboard. Same storage, same renderer, same edge cache — nothing about how it was created changes how it is served, and anything you build through the API stays fully editable in the dashboard afterwards (and the other way round).

Created in the dashboardCreated through the API
Landing page — content, Page Builder pages, version historyyesyes
Shield — rules, presets, block screens, decoysyesyes
All four template slotsyesyes
Geo Filters, deeplink behaviour, Privacyyesyes
Statistics, visit logs, A/B testsyesyes
Published to the edge cache on every changeyesyes
Belongs to one team memberyes — whoever created itno — it belongs to the project

The two people ask about most are worth spelling out:

  • Landing pages. PUT /api/v1/links/{link_id}/landing runs the same pipeline as the editor's save button — validate, normalize, size-check, snapshot into version history, publish to the edge. On a link, writing a page also activates it, so one call takes a link from empty to live. GET .../landing/history gives you the same version list the editor shows.
  • Shield. Same rule engine, same presets, same decoy resolution and the same readiness checks — a half-built block screen is refused rather than saved into a link that would 404 real visitors. You can send the full config inline on PUT /api/v1/links, or just name a shield_preset and let the API generate the rules and the block screen for you.

There is no reduced "API-lite" version of either. If a capability exists on a link, the API reaches it.

Ownership

An API key is a project credential, not a personal one — it is routinely shared with an automation tool, an agency or a script. So a link it creates belongs to the project rather than to any one team member: it is stored with created_by_api: true and no individual creator.

This has no effect on serving, statistics, quotas or billing. It shows up in exactly one place — dashboard permissions on teams that use per-member roles:

  • The project owner and admins manage API-created links exactly like any other. Nothing changes.
  • A team member restricted to their own links can see an API-created link, but needs the edit other members' links permission to change it — the link is not theirs, and by design was not created by anyone in particular.
  • Solo accounts and single-owner projects are unaffected, since the owner can already edit everything.

If one specific person should own a link long-term, create it from the dashboard under their account; otherwise grant the edit other members' links permission to whoever operates the integration.

Demos & Examples

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

🔗 <a href="https://github.com/linkscale-to" target="_blank" rel="noopener noreferrer">LinkScale GitHub - Code Examples</a>

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.

Browse API endpoints