{"openapi":"3.0.3","info":{"title":"LinkScale API","description":"Powerful link management API for creating, managing, and tracking shortened links.\n\n## Authentication\n\nAll API requests require authentication using an API key. Include your API key in the Authorization header:\n\n```\nAuthorization: Bearer your_api_key_here\n```\n\n## Changelog\n\nEverything that has changed in this API, newest first. Check here before you\nship — new capabilities show up in this list before you would notice them\nanywhere else.\n\n### Versioning\n\nThe `version` field of this document (currently **1.14.0**) moves with the API\nand follows semantic versioning:\n\n| Bump | Means |\n|---|---|\n| **MAJOR** | A breaking change — a field or endpoint removed, a response shape changed, a previously optional field made required. |\n| **MINOR** | A new endpoint, a new optional field, a new accepted value. Existing integrations keep working untouched. |\n| **PATCH** | Documentation corrections and bug fixes with no contract change. |\n\n**Nothing is removed without warning.** A field on its way out is first marked\n`deprecated` in this reference and listed under *Deprecated* below, and it keeps\nworking for at least one MINOR cycle. Deprecated fields are still accepted, so\nan integration that has not migrated yet will not start failing.\n\nTwo habits keep you safe against MINOR releases: **ignore response fields you\ndo not recognise** rather than validating strictly against a fixed shape, and\n**never assume a list field is a merge** — several (`geo_rules`, Shield's\n`rules`) replace the whole array.\n\n---\n\n### 2026-10-04 - 1.14.0\n\n**Added - stored integration reads and MCP guidance**\nEight GET routes under /api/v2/integrations expose connections, metrics,\ntracking links, account summaries, statements and fan health. Explicit\nintegrations.read and current project/account rights apply. The Integrations\nand MCP sections explain common revenue readings, freshness, paging,\nscheduling limits and the read-only review workflow. This documents the\nimplemented contract; deployment and account eligibility must be verified.\n\n---\n\n### 2026-08-06 — 1.13.0\n\n**Added — folders are fully manageable from the API**\n`POST /api/v1/folders` creates one, and `GET`, `PATCH` and `DELETE\n/api/v1/folders/{folder_id}` read, rename and delete it. Only the folder\n*listing* existed before, so an integration could see folders but never make\none. Permissions: `folders.create`, `folders.read`, `folders.edit`,\n`folders.delete` — these already existed on your API keys and finally have\nendpoints behind them.\n\n**Added — file a link into a folder when you create or update it**\n`PUT /api/v1/links` and `PATCH /api/v1/links/{link_id}` accept `folder_id`\n(single folder, `null` to clear) and `folders` (the full array — a link can\nsit in several folders at once; sending it replaces the whole list). Links\ncreated through the API no longer land outside every folder waiting to be\ndragged into place by hand. Sending both spellings at once, or a folder id\nfrom another project, is a `400`. See the new **Folders** section above.\n\n**Added — the folders of a link are returned**\n`link.folders` (an array of folder ids) is now on `GET /api/v1/links`,\n`GET /api/v1/links/{link_id}`, and echoed by both write verbs. It is always\npresent — `[]` for a link outside every folder — and is exactly the shape you\nsend back on a `PATCH`. `GET /api/v1/links/{link_id}` additionally returns a\nsibling `folders` array with the **names** resolved.\n\n**Added — `?folder_id=` on the link list**\n`GET /api/v1/links?folder_id=...` returns only the links inside one folder.\n\n---\n\n### 2026-08-03 — 1.12.0\n\n**Added — Media previews on every asset**\n`GET /api/v1/assets` and `GET /api/v1/assets/{file_id}` now return\n`thumbnail_url` (320px), `preview_url` (1024px), `media_kind`\n(`image`/`video`/`audio`/`document`) and `is_image`. Render a gallery\nstraight from those URLs instead of hand-building CDN transformations. The\ntwo preview URLs are `null` for anything that is not an image — video and\nPDF have no server-rendered still frame, so branch on `media_kind`.\n\n**Added — `sort` on the asset list**\n`?sort=newest|oldest|largest|smallest|name` (default `newest`). Anything else\nis a `400`.\n\n**Fixed — the asset list is finally paginable**\n`total` used to repeat the size of the page you just received; it now counts\nevery asset matching your filters. `limit`, `offset`, the new `count` (this\npage) and `has_more` come back with it. **If you were treating `total` as\n\"assets on this page\", switch to `count`.**\n\n**Fixed — `file_id` is back in the list, and reaches every file**\nThe list endpoint never returned `file_id`, so there was no way to go from a\nlisting to `GET /api/v1/assets/{file_id}`. It is returned again — and both\nendpoints now resolve dashboard-uploaded files too (their UUID is stored\nunder `provider_file_id`), which previously 404'd on the detail route.\n\n**Fixed — `?file_type=` no longer hides files with a generic MIME type**\nFiles stored as `application/octet-stream` are now matched on their filename\nextension as well, so `?file_type=video` returns the whole set.\n\n**Changed — `user_id` removed from the asset detail response**\nAn internal identifier that the list endpoint never exposed. No other field\nchanged.\n\n---\n\n### 2026-08-03 — 1.11.1\n\n**Documentation — what an API-created link inherits**\nA new section, *Links created through the API*, answers the question directly:\na link created with `PUT /api/v1/links` is the same object as one created in\nthe dashboard, with the same landing-page, Shield, template, Geo Filter and\nPrivacy capabilities, and it stays editable in the dashboard afterwards. The\none difference is **ownership** — an API key is a project credential, so the\nlink belongs to the project rather than to a team member. That matters only\nfor dashboard permissions on teams using per-member roles, and is spelled out\nthere. No API change.\n\n---\n\n### 2026-08-03 — 1.11.0\n\n**Fixed — `GET /api/v1/trending-links` now actually exists**\nThis endpoint has been documented for a while but was never wired up: calling\nit returned a 404 HTML page. It is live now, behind the\n`statistics.read_project` permission, and returns exactly the payload\ndescribed below. The project is resolved from your API key — `project_id` is\noptional, and sending another project's id is a `403` rather than a\ncross-project read.\n\n---\n\n### 2026-08-03 — 1.10.0\n\n**Added — Link Privacy (`privacy.links`)**\nMake a link **private** from the API, the same switch as *Privacy* in the\ndashboard: `{\"privacy\": {\"links\": true}}` on `PUT /api/v1/links` and\n`PATCH /api/v1/links/{id}`. The destinations the link renders are then served\nthrough a short redirect on your own domain (`https://<your-domain>/go/<token>`),\nso the real URL never appears in the HTML a crawler reads. The call that flips\nthe switch provisions those redirects — there is nothing else to enable.\nSend `{\"links\": false}` to go back to normal.\n\nThe flag is **returned by every link read** — `GET /api/v1/links`,\n`GET /api/v1/links/{id}` and the `PUT /api/v1/links` response — always as\n`privacy: { links: <boolean> }`, `false` on a link that was never made\nprivate. See the **Link Privacy** section.\n\n### 2026-08-02 — 1.9.0\n\n**Added — every template family is now selectable on a link**\nA link has four template slots, and until now only the landing one could be\nset through the API. All four are now accepted on `PUT /api/v1/links` and\n`PATCH /api/v1/links/{id}`, and returned together under `templates` by\n`GET /api/v1/links/{id}`:\n`cs_template` (landing), `cs_1-step` (first-step gate), `cs_3dots_template`\n(direct 3-dots overlay) and `cs_3dots_click_template` (click 3-dots overlay).\nSend `null` on any of them to detach. See *Templates — the three families*.\n\n**Added — `?kind=` on the Templates endpoints**\n`GET /api/v1/templates` and `GET /api/v1/templates/{template_id}` take\n`kind=landing|first_step|three_dots`, so the ids for the new slots are\ndiscoverable. Defaults to `landing`, so existing calls are unchanged. Both\nresponses now echo the `kind` they served. `GET /api/v1/templates` also takes\n`?summary=true` for a trimmed listing — recommended, since the default\nresponse returns full template documents and a v2 template embeds a whole page.\n\n**Changed — template ids are checked against your project**\nA template id that does not belong to the authenticated project is now\nrejected with `400` naming the field, on every slot. It was previously\naccepted for `cs_template`, which let a link be pointed at another project's\ntemplate. If you send ids you did not get from `GET /api/v1/templates`, check\nthem before upgrading.\n\n**Fixed — `PATCH /api/v1/links/{id}` accepts `cs_template`**\nIt previously silently ignored it; the only way to attach a landing template\nto an existing link was `PUT|PATCH /api/v1/links/{id}/dynamic-overrides`,\nwhich still works and remains the right call when you set the template and\nits `dynamic_informations` / `dynamic_links` together.\n\n**Fixed — Templates response shapes documented correctly**\n`GET /api/v1/templates` returns `{ templates, kind, project }` and\n`GET /api/v1/templates/{template_id}` returns `{ template, kind, project }`.\nThis reference previously described a `{ success, data }` envelope neither of\nthem has ever returned. No API change — the documentation was wrong.\n\n---\n\n### 2026-08-01 — 1.8.0\n\n**Added — Geo Filters (`geo_rules`)**\nPer-visitor overrides on a link: block a country, redirect a region to a\nlocalized destination, or serve a different landing page per browser language.\nSettable on `PUT /api/v1/links` and `PATCH /api/v1/links/{id}`, returned in full\nby `GET /api/v1/links/{id}`, and summarised as `geo_rules_count` on\n`GET /api/v1/links`. See the **Geo Filters** section for the model and examples.\n\n**Added — Shield (`/api/v1/links/{link_id}/shield`)**\nTraffic filtering and cloaking as a first-class resource: read, replace (`PUT`),\npatch, and disable (`DELETE`) a link's Shield configuration, plus\n`GET /api/v1/shield` for the condition vocabulary, `GET /api/v1/shield/presets`\nfor the built-in presets, and `GET /api/v1/shield/bots` for the crawler\nregistry. See the **Shield** section.\n\n**Deprecated — `geolocation_enabled`, `geolocation_redirects`**\nThese two fields on `PATCH /api/v1/links/{id}` were never read by the serve\nlayer: setting them did nothing, and links \"configured\" with them were not\ngeo-targeted at all. They are still accepted so existing callers do not break,\nbut they are now discarded rather than stored. **Migrate to `geo_rules`** — a\n`{ countries, url }` entry becomes a rule with `t: \"d_l\"`.\n\n**Fixed — link detail response documented correctly**\n`GET /api/v1/links/{id}` returns `{ link, project }`. It was previously\ndocumented as `{ success, data }`, which never matched the actual response. The\nendpoint itself is unchanged; only the reference was wrong.\n\n### 2026-07-24\n\n**Added — Landing Pages (v2)**\nRead and write Page Builder v2 pages on links and templates\n(`/api/v1/links/{link_id}/landing`, `/api/v1/templates/{template_id}/landing`),\nwith version history, per-link `dynamic-overrides`, and the\n`/api/v1/landing-engine` contract and example endpoints.\n\n### 2026-03-31\n\n**Added — Visit logs**\n`GET /api/v1/logs`, `/api/v1/links/{link_id}/logs` and\n`/api/v1/folders/{folder_id}/logs` — cursor-paginated visit history with masked\nIPs and each visit's clicks merged in. See the **API Logs** section.\n\n### 2026-02-19\n\n**Added — Social networks & trending links**\nConnected-account analytics, post metrics and history under\n`/api/v1/social-networks`, plus `GET /api/v1/trending-links`.\n\n### 2025-10-25\n\n**Added — Folders**\n`/api/v1/folders` and folder-scoped statistics.\n\n### 2025-10-17\n\n**Initial public API** — links, templates, assets and statistics.\n\n---\n\n> **Keeping this list current (internal note).** This changelog lives in the\n> `info.description` of `static/openapi.yaml` and nowhere else — there is no\n> second copy to drift out of sync. When you change the API: add a dated entry\n> at the top of the list under the right label (**Added** / **Changed** /\n> **Deprecated** / **Removed** / **Fixed**), say what a consumer must *do*\n> rather than what the code now does, and bump `info.version` plus the number\n> quoted under *Versioning* above.\n\n## File Upload System\n\nLinkScale uses a secure, three-step upload process with Uploadcare CDN for optimal performance and security.\n\n### Quick Start Guide\n\n**Step 1: Get Upload Signature**\n```javascript\nPUT /api/v1/assets\nBody: { \"expiration_minutes\": 10 }\n→ Returns: upload_config with signature\n```\n\n**Step 2: Upload to Uploadcare**\n```javascript\nPOST upload_config.upload_url\nFormData with: file, signature, public_key, metadata\n→ Returns: { \"file\": \"uuid-file-id\" }\n```\n\n**Step 3: Poll for Validation**\n```javascript\nGET /api/v1/assets/{file_id}\nPoll every 2 seconds until 200 OK (usually 2-3 seconds)\n→ Returns: Complete asset with CDN URL\n```\n\n### Why This Approach?\n\n- **Security**: Time-limited signatures prevent unauthorized uploads\n- **Performance**: Direct CDN upload, no server bottleneck\n- **Scalability**: Files never transit through your server\n- **Reliability**: Automatic validation and metadata extraction\n- **Flexibility**: Support for images, videos, documents, and more\n\n### Supported File Types\n\n- **Images**: PNG, JPEG, GIF, WebP, SVG (with dimensions, format, DPI)\n- **Videos**: MP4, WebM, MOV (with duration, bitrate, codecs)\n- **Audio**: MP3, WAV, OGG, M4A (with duration, bitrate)\n- **Documents**: PDF, JSON, XML, TXT, CSV\n\n### Complete Implementation\n\nSee the detailed documentation in the **Assets** endpoints for complete code examples in JavaScript/Node.js.\n\n## Templates — the three families\n\nA link is not skinned by one template. It has **four template slots**, backed\nby **three separate families**, and each one dresses a different moment of the\nvisitor's journey. They are independent: a link can use all four, or one, or\nnone.\n\n| Slot on the link | Family (`?kind=`) | What it skins |\n|---|---|---|\n| `cs_template` | `landing` | The landing page served for a `t: \"l_p\"` link. |\n| `cs_1-step` | `first_step` | The **1-step verification gate** shown before the landing page. |\n| `cs_3dots_template` | `three_dots` | The **direct** \"open in browser\" escape overlay, shown on arrival. |\n| `cs_3dots_click_template` | `three_dots` | The **click** overlay, shown when a visitor taps a link button. |\n\n### Attaching one, end to end\n\n```bash\n# 1. Find the template. Ids are unique PER FAMILY, so always pass `kind`.\ncurl -s \"https://dashboard.linkscale.to/api/v1/templates?kind=three_dots&summary=true\" \\\n  -H \"Authorization: Bearer lk_xxxxxxxxxxxx\"\n\n# 2. Attach it — on create, or on an existing link.\ncurl -X PATCH https://dashboard.linkscale.to/api/v1/links/<LINK_ID> \\\n  -H \"Authorization: Bearer lk_xxxxxxxxxxxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"cs_3dots_template\": \"69df9e86d30eb3d7dff9d2ad\"}'\n\n# 3. Read all four slots back.\ncurl -s https://dashboard.linkscale.to/api/v1/links/<LINK_ID> \\\n  -H \"Authorization: Bearer lk_xxxxxxxxxxxx\" | jq .templates\n```\n\nSend `null` on any slot to detach it. Detaching one slot never touches the\nothers.\n\n### Things worth knowing before you build\n\n- **Ids are unique per family, not globally.** A first-step id read through\n  `GET /api/v1/templates/{id}` without `?kind=first_step` returns `404` —\n  it is being looked up among the landing templates. Always carry the `kind`\n  alongside the id.\n- **A template must belong to your project.** An id from another project is\n  rejected with `400` naming the field, never silently attached.\n- **Attaching sets the design, not the behaviour.** A 3-dots template does\n  not switch the overlay on — that is `deeplinks_logic` — and a first-step\n  template does not open the gate, which is\n  `1-step-verification-page.enable`. Attach the template to a surface you\n  have already enabled, or nothing changes for the visitor.\n- **The template stays the source of truth.** All four slots store a\n  *reference*, resolved at serve time. Editing the template in the dashboard\n  updates every link pointing at it, with no per-link re-save.\n- **An inline page beats the landing template.** If a link carries its own\n  Page Builder v2 page (see the **Landing Pages** endpoints), that page is\n  served and `cs_template` is ignored.\n- **Only landing templates can be created through the API.** `PUT`, `PATCH`\n  and `DELETE` on `/api/v1/templates` act on the `landing` family; first-step\n  and 3-dots templates are authored in the dashboard and read here.\n\n### One template, many links\n\nFor the common \"same design, different person per link\" case, do not clone\nthe template. Attach the one `cs_template` to every link and override just\nthe name and photo per link with `dynamic_informations` / `dynamic_links` —\ndescribed next, and settable in the same call through\n`PUT|PATCH /api/v1/links/{link_id}/dynamic-overrides`.\n\n## Dynamic Features\n\nLinkScale provides powerful dynamic features that allow you to reuse templates and configurations while customizing specific elements per link.\n\n### Dynamic Informations\n\nOverride 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.\n\n**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.\n\n**Key Features:**\n- Override template name with custom display name\n- Override template profile picture with custom image\n- Granular control over profile picture styling (size, borders, etc.)\n- Master toggles to enable/disable overrides\n- Only works when `cs_template` is specified\n\n**Example:**\n```json\n{\n  \"cs_template\": \"507f1f77bcf86cd799439011\",\n  \"dynamic_informations\": {\n    \"enabled\": true,\n    \"pp_enabled\": true,\n    \"n\": \"John Doe\",\n    \"pp\": {\n      \"url\": \"https://cdn.example.com/john.jpg\",\n      \"enabled\": true,\n      \"size\": 150,\n      \"border\": {\n        \"color\": \"#4A90E2\",\n        \"style\": \"solid\",\n        \"width\": 3\n      }\n    }\n  }\n}\n```\n\n### Dynamic Links\n\nDynamically manage and customize link arrays within your landing pages for flexible content management.\n\n## Geo Filters\n\nGeo 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).\n\n### The model in one paragraph\n\nA 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.\n\n### Anatomy of a rule\n\nA rule answers two questions — **who matches** (`detection_type` + its criteria) and **what they get** (`t` + its payload).\n\n```json\n{\n  \"detection_type\": \"ip\",          // who: by IP geolocation, or \"browser_language\"\n  \"countries\": [\"US\", \"CA\"],       // ...specifically these countries\n  \"t\": \"d_l\",                      // what: redirect (\"block\" / \"d_l\" / \"l_p\")\n  \"url\": \"https://example.com/na\"  // ...to here\n}\n```\n\n| Field | Applies to | Meaning |\n|---|---|---|\n| `enabled` | all | Defaults to `true`. A rule that is not enabled is skipped before anything else is read. |\n| `detection_type` | all | `ip` (default) or `browser_language`. |\n| `location` | `ip` | One ISO-3166-1 alpha-2 code, **or** a group key that expands to many countries. |\n| `countries` | `ip` | ISO-3166-1 alpha-2 codes; matches **any** of them. |\n| `regions` / `cities` | `ip` | Narrow the match inside the matched country. Raises the rule's priority. |\n| `language` | `browser_language` | Matched as a **substring**, so `\"fr\"` also catches `fr-CA`. |\n| `t` | all | `block` → 404, `d_l` → redirect to `url`, `l_p` → serve a landing page. |\n| `url` | `t: d_l` | Where matched visitors are sent. Required for `d_l`. |\n| `cs_template` | `t: l_p` | Project template ObjectId, resolved at serve time. |\n| `landing_v2_page` | `t: l_p` | Inline Page Builder v2 page. Takes precedence over `cs_template`. |\n\n**Group keys accepted by `location`:** `AFRICA`, `MIDDLE_EAST`, `EUROPE`, `ASIA`, `NORTH_AMERICA`, `SOUTH_AMERICA`, `OCEANIA`, `LOW_GDP_PER_CAPITA`.\n\n### Only one rule wins\n\nAll enabled rules are evaluated, then exactly one is applied — the most specific:\n\n| Priority | Rule shape |\n|---|---|\n| 3 (highest) | `ip` **plus** `regions` and/or `cities` |\n| 2 | `browser_language` |\n| 1 (lowest) | `ip` alone |\n\nTies 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.\n\n### Things that will surprise you\n\n- **`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 `[]`.\n- **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.\n- **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.\n- **`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.\n- **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.\n- **`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`.\n\n### Worked example\n\nBlock France, route North America to a regional page, and give French speakers elsewhere a localized destination:\n\n```bash\ncurl -X PATCH https://app.linkdm.me/api/v1/links/<LINK_ID> \\\n  -H \"Authorization: Bearer lk_xxxxxxxxxxxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"geo_rules\": [\n      { \"detection_type\": \"ip\", \"location\": \"FR\", \"t\": \"block\" },\n      { \"detection_type\": \"ip\", \"countries\": [\"US\", \"CA\"], \"t\": \"d_l\", \"url\": \"https://example.com/north-america\" },\n      { \"detection_type\": \"browser_language\", \"language\": \"fr\", \"t\": \"d_l\", \"url\": \"https://example.com/fr\" }\n    ]\n  }'\n```\n\nA 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.\n\n### Appending a rule safely\n\n```js\nconst headers = { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' };\n\n// 1. Read the rules that already exist — PATCH would otherwise wipe them.\n//    The detail endpoint responds with { link, project }.\nconst res = await fetch(`https://app.linkdm.me/api/v1/links/${linkId}`, { headers });\nconst { link } = await res.json();\nconst existing = link.geo_rules ?? [];\n\n// 2. Send the full list back with the new rule appended.\nawait fetch(`https://app.linkdm.me/api/v1/links/${linkId}`, {\n  method: 'PATCH',\n  headers,\n  body: JSON.stringify({\n    geo_rules: [...existing, { detection_type: 'ip', location: 'DE', t: 'd_l', url: 'https://example.com/de' }]\n  })\n});\n```\n\n## API Logs\n\nHow the `/api/v1/.../logs` endpoints work, why they scale, and the caveats you should know before promising things to API consumers.\n\n---\n\n### TL;DR — How the system works (for API consumers)\n\nA 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.\n\n#### How a consumer integrates — 30 seconds\n\n```bash\ncurl https://app.linkdm.me/api/v1/links/<LINK_ID>/logs?limit=100 \\\n  -H \"Authorization: Bearer lk_xxxxxxxxxxxx\"\n```\n\n```json\n{\n  \"success\": true,\n  \"data\": [\n    {\n      \"_id\": \"65f1a2…\",\n      \"timestamp\": \"2026-04-28T11:42:13.512Z\",\n      \"country\": \"FR\",\n      \"city\": \"Paris\",\n      \"ip\": \"82.**.**.117\",\n      \"userAgent\": \"Mozilla/5.0 …\",\n      \"device_type\": \"mobile\",\n      \"bot\": 0,\n      \"host\": \"linkdm.me\",\n      \"referer\": \"https://t.co/…\",\n      \"clicks\": [\n        { \"url\": \"https://example.com\", \"btn_id\": \"btn_a\", \"created_at\": \"…\", \"is_final\": 1 }\n      ]\n    }\n  ],\n  \"next_cursor\": \"2026-04-28T11:42:13.512Z\",\n  \"has_more\": true\n}\n```\n\nTo walk every page, loop with the `next_cursor` until `has_more === false`:\n\n```js\nlet cursor = null;\ndo {\n  const url = `https://app.linkdm.me/api/v1/links/${linkId}/logs?limit=100${cursor ? `&last_timestamp=${encodeURIComponent(cursor)}` : ''}`;\n  const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });\n  const { data, next_cursor, has_more } = await res.json();\n  for (const visit of data) { /* process */ }\n  cursor = next_cursor;\n} while (cursor);\n```\n\n#### What the consumer must know\n\n| Limit | Value | Why |\n|---|---|---|\n| Max history window | **30 days** | ClickHouse perf guardrail; older data not retrievable through this endpoint |\n| Page size | **100 max** (default 100) | Bounds memory + response size |\n| Rate limit | **2 req/s per API key** | Backpressure on ClickHouse |\n| `from`/`to` window | **31 days max** | Joi-enforced, returns 400 if exceeded |\n| Clicks per visit | **50 max** in `clicks[]` | One hot visit can't blow up the response |\n| IP format | **always masked** | Raw IPs never leave the server (IPv4: `a.**.**.d`, IPv6: `aaaa:bbbb:****:…:zzzz`) |\n\n#### HTTP status codes\n\n| Code | When |\n|---|---|\n| `200` | Success |\n| `400` | Validation error (bad cursor, bad limit, bad date range) |\n| `401` | Missing / malformed / unknown / inactive API key |\n| `403` | API key lacks `logs.read_link` / `logs.read_folder` / `logs.read_project` permission |\n| `404` | `link_id` / `folder_id` doesn't belong to the caller's project |\n| `429` | Rate limit (2 rps) exceeded; `Retry-After: 1` |\n| `500` | Unexpected server error (ClickHouse down, etc.) |\n\n#### The auth flow under the hood\n\n1. Consumer sends `Authorization: Bearer lk_xxxxx`.\n2. Server SHA-256-hashes the key and looks it up in `projects_api_keys` (Mongo). Raw keys are never stored.\n3. The matched key's `permissions` object (e.g. `{ logs: { read_link: true } }`) is loaded onto the request.\n4. 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`.\n5. 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.\n6. Every request is recorded in `projects_api_logs` for audit.\n\n---\n\n### Endpoints\n\n| Endpoint | Scope |\n|---|---|\n| `GET /api/v1/logs` | All visits across the project |\n| `GET /api/v1/links/{link_id}/logs` | One link's visits |\n| `GET /api/v1/folders/{folder_id}/logs` | All visits for links in one folder |\n\nAll three share one controller (`src/controllers/public-api/logs/getLogsController.js`) and one ClickHouse helper (`src/lib/helpers/stats/clickhouseLogs.js`).\n\n---\n\n### Authentication & rate limiting\n\n- `Authorization: Bearer lk_xxxxx` (validated against `api_keys` in MongoDB).\n- Per-scope permission required: `logs.read_project`, `logs.read_folder`, `logs.read_link`.\n- Rate limit: **2 requests / second / API key** (enforced in `handleApiKeyRoute.js`).\n- All requests are written to `projects_api_logs` for audit.\n\nAt the rate limit, the practical ceiling is **200 visits/second per key** — fine for almost any sane export job.\n\n---\n\n### Query parameters\n\n| Param | Default | Notes |\n|---|---|---|\n| `limit` | `100` | Min 1, max 100. |\n| `last_timestamp` | — | Cursor for the next page (see below). |\n| `from`, `to` | — | ISO datetimes. Optional, but capped by the date-range limit middleware. |\n| `source` | `visits` | `visits` (one row per visit, with embedded `clicks[]`) or `clicks` (one row per click event). |\n| `country` | — | ISO country code. **Only honored on `source=visits`.** |\n| `visitor_type` | `all` | `humans` / `bots` / `all`. **Only honored on `source=visits`.** |\n\n---\n\n### Response envelope\n\n```json\n{\n  \"success\": true,\n  \"data\": [ /* up to `limit` rows, newest first */ ],\n  \"next_cursor\": \"2026-04-28T11:42:13.512Z\",\n  \"has_more\": true\n}\n```\n\n- `next_cursor` = the `timestamp` of the last row, or `null` when the page is partial.\n- `has_more` = `true` while a full page is returned. May produce one false-positive empty page on the boundary (no data is lost).\n\n#### Row shape (`source=visits`)\n\nTop-level visit fields:\n`_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[]`.\n\n`clicks[]` carries up to **50 most recent clicks per visit**, each with: `url`, `btn_id`, `position`, `btn_v`, `action_type`, `is_final`, `created_at`.\n\n#### IP masking\n\n- Raw IPs are stored in ClickHouse but never leave the server.\n- IPv4: `12.34.56.78` → `12.**.**.78`\n- IPv6: `2a01:cb00:1234:5678:9abc:def0:1234:5678` → `2a01:cb00:****:****:****:****:****:5678`\n- IPs embedded in `userAgent` / `referer` strings are also masked by regex replacement.\n\n---\n\n### Pagination — how to walk\n\n```http\nGET /api/v1/links/<id>/logs?limit=100\nAuthorization: Bearer lk_xxx\n```\nSave `next_cursor` from the response, then:\n```http\nGET /api/v1/links/<id>/logs?limit=100&last_timestamp=<next_cursor>\n```\nStop when `has_more === false` (or `data` is empty).\n\nThe 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.\n\n---\n\n### Why this is fast (the ClickHouse side)\n\n`stats` table:\n- Sort key: `(project_id, timestamp, link_id, user_id)`\n- Partitioned monthly on `timestamp`\n- Bloom-filter index on `link_id` and `mongo_id`\n\n`clicks_stats` table:\n- Sort key: `(project_id, created_at, link_id, mongo_id)`\n- Bloom-filter index on `stats_id` and `link_id`\n- ReplacingMergeTree\n\nThe cursor query\n```sql\nSELECT … FROM stats\nWHERE timestamp >= now() - INTERVAL 30 DAY\n  AND project_id = ?\n  AND link_id = ?           -- when scoped to a link\n  AND timestamp < <cursor>  -- when paginating\nORDER BY timestamp DESC\nLIMIT 100\n```\nhits the sort-key prefix `(project_id, timestamp, …)`, so ClickHouse only reads the relevant granules from one or two monthly partitions — not the table.\n\nThe follow-up clicks query\n```sql\nSELECT … FROM clicks_stats WHERE stats_id IN (<≤100 ids>)\n```\nuses 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.\n\n#### Bounds, in plain numbers\n\nFor a single page (`limit=100`):\n\n- 1 ClickHouse scan over ≤2 monthly partitions of `stats`, returning ≤100 rows.\n- 1 ClickHouse scan over `clicks_stats` with a bloom-filtered `stats_id IN (…)`, returning ≤5,000 rows (100 × 50).\n- Network: \\~few hundred KB at most.\n- Wall time: typically tens of ms; worst-case low hundreds.\n\nClickHouse is sized for this. The 30-day fence + sort-key prefix is what keeps the first query bounded.\n\n---\n\n### What's solid\n\n#### Auth & authorization\n- 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`).\n- Stored as `sha256(api_key)` in `projects_api_keys` (`apiKeyAuth.js`). Plaintext keys never logged, even in dev mode.\n- Auth aggregation requires `is_active: true`, plus successful `$lookup` joins to both `users` and `projects` (`preserveNullAndEmptyArrays: false`). A deleted user or project = 401.\n- 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`.\n- 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.\n\n#### SQL safety\n- 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.\n- Joi validates types and lengths upstream of `esc()`:\n  - `country` — `string().max(10)`\n  - `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)\n  - `limit` — `integer().min(1).max(100)`\n  - `source` / `visitor_type` — strict enum\n  - `from` / `to` — ISO 8601, plus a 31-day window cap from `withDateRangeLimit`\n- `link_id` and `folder_id` are validated with `ObjectId.isValid` before they touch SQL.\n- Audit log writes (`projects_api_logs`) use the validated `query` object, so a malicious `last_timestamp` can't bloat Mongo storage.\n\n#### Performance bounds\n- Sort keys and bloom indexes line up with every WHERE clause the controller emits — no full scans on a healthy table.\n- 30-day fence is unconditional (see Caveats §1).\n- Clicks-per-visit cap of 50 (`ROW_NUMBER` window) prevents one hot visit from blowing up the response.\n- One batched query for visits, one batched query for their clicks. Never N+1.\n- Rate limit (2 rps/key) gives ClickHouse natural backpressure — burst is bounded.\n\n#### Data privacy\n- Raw IPs never leave the server. Masked at serialization (`maskIp` for direct-IP fields, `maskIpAddresses` for IPs embedded in `userAgent`/`referer`).\n- Masking covers IPv4 (`12.**.**.78`), IPv6 (`2a01:cb00:****:…:5678`), and click-level `ip` fields when present.\n\n#### Pagination correctness\n- Cursor works on both `source=visits` (cursor column `timestamp`) and `source=clicks` (cursor column `created_at`, aliased back to `timestamp` in the response).\n- Country/bot filters are correctly skipped for `source=clicks` because those columns don't exist on `clicks_stats` (instead of erroring).\n\n---\n\n### Known caveats — read these before promising anything\n\n#### 1. 30-day hard ceiling\n\nThe 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.\n\n#### 2. `country` / `visitor_type` only work on `source=visits`\n\nThose 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.\n\n#### 3. Cursor tie-breaking\n\nCursor 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.\n\n#### 4. `has_more=true` boundary false-positive\n\nIf 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.\n\n#### 5. Click cap per visit\n\nA 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.\n\n#### 6. Rate limit is a soft cap\n\nThe 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.\n\n#### 7. Permissions snapshot at auth time\n\nPermissions 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.\n\n#### 8. Folder existence is not asserted\n\n`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.\n\n---\n\n### What to do if it ever stops being fast\n\n1. Run `scripts/clickhouse/diagnostics/diagnose_clicks_stats_schema.js` — confirms sort key + indexes are still in place.\n2. 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).\n3. 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\".\n\n---\n\n### Audit log (defects found + fixed during the hardening pass)\n\n| # | Severity | Defect | Fix |\n|---|---|---|---|\n| 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 |\n| 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 |\n| 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) |\n| 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 |\n| 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` |\n\n## Shield — traffic filtering & cloaking\n\nShield 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.\n\n### The 30-second version\n\n```bash\n# Protect a link with a one-click profile: bots, proxies, VPNs and datacenter\n# IPs see the link's own landing page instead of the real destination.\ncurl -X PUT https://dashboard.linkscale.to/api/v1/links/<LINK_ID>/shield \\\n  -H \"Authorization: Bearer lk_xxxxxxxxxxxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"enabled\": true, \"preset\": \"instagram\"}'\n```\n\nThat 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.\n\n### The three surfaces\n\n| You want to... | Use |\n|---|---|\n| Ship a standard protection profile | `preset` — `instagram`, `bots_only`, `hard_404` |\n| Flip one traffic category | `buckets` — `bot_known`, `bot_unknown`, `net_proxy`, `net_vpn`, `net_datacenter`, `bot:<botId>` |\n| Express anything else | `rules` — the full condition tree |\n\nAll 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.\n\n### What a blocked visitor sees\n\nThe `block` screen is what makes Shield a *cloaking* tool rather than a firewall:\n\n- `not_found` — a plain 404.\n- `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.\n- `three_dots` — the \"open in browser\" overlay.\n- `do_nothing` — let it through (link default only).\n\nEach 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.\n\n### Presets\n\n| id | What it does | Block screen |\n|---|---|---|\n| `instagram` | Bots (including link scanners like `facebookexternalhit`), proxies, VPNs and datacenter IPs see a decoy. Also cuts every deeplink on that traffic, since a scanner following one would expose the real destination. | Decoy landing (the link's own, by default) |\n| `bots_only` | Every bot gets the 3-dots overlay; real human proxy / VPN traffic is untouched. | 3-dots overlay |\n| `hard_404` | Bots, proxies, VPNs and datacenter IPs get a plain 404. | 404 |\n\n### Build against the contract, not against this table\n\n`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.\n\n### Legacy links\n\nLinks 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\"`.\n\n## Link Privacy — hiding the destination\n\nShield decides *who* gets the real page. **Privacy** decides whether the real destination is written into the page at all.\n\nA 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.\n\n```bash\n# Make an existing link private\ncurl -X PATCH https://dashboard.linkscale.to/api/v1/links/<LINK_ID> \\\n  -H \"Authorization: Bearer lk_xxxxxxxxxxxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"privacy\": {\"links\": true}}'\n```\n\n| Mode | What a bot reads in the page |\n|---|---|\n| Normal (default) | `https://your-real-destination.com/product/summer-sale` |\n| Private | `https://your-domain.com/go/aX9f2k` |\n\n### What you need to switch on\n\nOnly 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.\n\n### Things worth knowing\n\n- **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.\n- **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.\n- **The redirect is on your domain**, not a shared one, so your links are not exposed to another customer's reputation.\n- **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.\n- **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.\n\n## Folders — organising links\n\nFolders 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.\n\n### The one thing to know\n\n**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*:\n\n```json\n// 1. create the folder (or reuse an id from GET /api/v1/folders)\nPOST /api/v1/folders\n{ \"name\": \"Q3 Campaign\" }\n// -> { \"folder\": { \"_id\": \"6a7491caaa4bce8130ea5a62\", \"links_count\": 0, ... } }\n\n// 2. create links straight into it — no manual move afterwards\nPUT /api/v1/links\n{ \"type\": \"d_l\", \"u\": \"promo\", \"domain\": \"yourdomain.com\",\n  \"url\": \"https://example.com\", \"folder_id\": \"6a7491caaa4bce8130ea5a62\" }\n\n// 3. move an existing link\nPATCH /api/v1/links/{link_id}\n{ \"folder_id\": \"6a7491caaa4bce8130ea5a62\" }\n```\n\n### `folder_id` vs `folders`\n\nA link may sit in **several folders at once**, so the underlying field is an array:\n\n| Field | Use it for |\n|---|---|\n| `folder_id` | The common single-folder case. Shorthand for a one-element `folders` array. `null` takes the link out of every folder. |\n| `folders` | Multi-folder membership. **Replaces the whole list** (like `geo_rules`), so include the ids you want to keep; `[]` empties it. |\n\nSending 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.\n\n### Reading it back\n\n- `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`.\n- `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.\n- `GET /api/v1/links?folder_id=...` — list the links inside one folder.\n\n### Deleting a folder\n\n`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.\n\n### Permissions\n\nEach 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.\n\n## Links created through the API\n\nA 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).\n\n| | Created in the dashboard | Created through the API |\n|---|---|---|\n| Landing page — content, Page Builder pages, version history | yes | yes |\n| Shield — rules, presets, block screens, decoys | yes | yes |\n| All four template slots | yes | yes |\n| Geo Filters, deeplink behaviour, Privacy | yes | yes |\n| Statistics, visit logs, A/B tests | yes | yes |\n| Published to the edge cache on every change | yes | yes |\n| Belongs to one team member | yes — whoever created it | no — it belongs to the **project** |\n\nThe two people ask about most are worth spelling out:\n\n- **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.\n- **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.\n\nThere is no reduced \"API-lite\" version of either. If a capability exists on a link, the API reaches it.\n\n### Ownership\n\nAn 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.\n\nThis 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:\n\n- **The project owner and admins** manage API-created links exactly like any other. Nothing changes.\n- **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.\n- **Solo accounts and single-owner projects are unaffected**, since the owner can already edit everything.\n\nIf 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.\n\n## Demos & Examples\n\nLooking for practical examples and ready-to-use scripts? Visit our GitHub organization for concrete implementations:\n\n**🔗 <a href=\"https://github.com/linkscale-to\" target=\"_blank\" rel=\"noopener noreferrer\">LinkScale GitHub - Code Examples</a>**\n\nYou'll find:\n- **Complete upload workflows** with Node.js implementations\n- **Link management scripts** for batch operations\n- **Integration examples** for common use cases\n- **Real-world scenarios** and best practices\n\nThese repositories provide production-ready code you can use as a foundation for your own implementations.\n","version":"1.14.0","contact":{"name":"LinkScale Support","url":"https://linkscale.to/support","email":"contact@linkscale.to"},"license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"}},"servers":[{"url":"https://dashboard.linkscale.to","description":"Production server"}],"security":[{"BearerAuth":[]}],"tags":[{"name":"Integrations","description":"Read stored OnlyFans, MYM and Fanvue data through one project-scoped API. All eight routes below are GET-only. Access depends on current feature eligibility and the explicit integrations.read scope. Existing keys gain no scopes automatically. This reference describes the implemented contract; it does not establish deployment or live account access.\n\n## Start here\n\n1. Create or edit a project API key in the [dashboard](https://dashboard.linkscale.to/mcp) and explicitly grant integrations.read.\n2. List connections and follow pagination.next_cursor.\n3. Inspect available_data, then read tracking-links, metrics, account, statements or fan-health using the returned connection ID.\n4. Retain source freshness, currency, actual range and coverage with every number.\n\nSee [MCP](#tag/MCP) for the equivalent assistant workflow. The separate partner API at /api/partner/v1 has its own daily-statistics contract; do not use partner keys or partner account IDs here.\n\n## Freshness and permissions\n\nfreshness.last_synced_at is the last successful source pull. observed_at only says when this response was served. A recent failed attempt does not refresh data. next_sync_at is scheduled, not guaranteed completion. Data may end at the stored watermark rather than today. available_data describes provider support, not imported data or permission to see money. Missing, disconnected or foreign connections are refused without revealing their existence. Revenue restrictions remain authoritative; never reconstruct hidden revenue from counters. Credentials, proxy details, account email, fan identities and raw worker errors are excluded.\n\n## Common money reading\n\nEvery tracking row adds **`revenue_reading`**, the common money field across\nOnlyFans, MYM and Fanvue. Existing fields remain compatible:\n\n- `amount`, `currency`: the stored reading in its original currency; no conversion\n  or cross-currency sum. Zero is a measurement; unknown/pending amounts are null.\n- `basis`: `ledger` (attributed payments), `counter` (platform readings), or null\n  when no authorized money reading is available.\n- `scope`: `window`, `lifetime` for a platform counter, or `stored_history` for\n  the ledger under the lifetime selector. OnlyFans' stored history must never\n  be presented as the platform's lifetime revenue.\n- `range`: the requested window's dates, null for lifetime/stored-history scope.\n- `status`: `available`, `pending`, `unavailable`; `reason` preserves the mapping\n  reason or says `awaiting_readings` / `not_available`.\n- `floor`: null without an amount, true for a recovering map or a counter whose\n  first reading is after the requested start. Other source limitations remain in\n  the surrounding response; false is not a claim of independently audited income.\n\nThe response also preserves `revenue.since` (connection/tracking dates), row\n`net_since_connection` / `net_since_tracking`, attribution checks, Fanvue\n`sources` and `fan_join`, and the dated `subscriber_split`. These are imported\nfacts; no provider request or new attribution calculation happens on read.\n\nAssistant tracking-link pages are ordered by `record_id` and may shrink to fit\nthe response budget. Follow **`page.next_offset`**, never `offset + limit`.\n`page.total` counts the source reading, and totals/caveats stay intact on every\npage. This is not a snapshot across sync runs: restart pagination if\n`connection.freshness.last_synced_at` changes. Statements keep a fixed page size;\na response too large to preserve intact is refused with 413. Reduce `limit`\nand restart statement pagination at page 0, or use REST. Fan health likewise\nasks for fewer weeks rather than silently discarding weeks.\n\n## Two pagination layers\n\nREST connection lists use pagination.next_cursor -> cursor; MCP uses next_after -> after. Tracking-link source coverage is independent of assistant paging: sources without history retain a 300-record cap disclosed by coverage.has_more. MCP page.next_offset only pages through the returned source reading and can advance by less than the requested limit. Finishing those pages does not retrieve capped-out records; REST uses the same source cap. Never add repeated totals. Restart if last_synced_at changes during paging. Statements keep a fixed page size and advance page.index + 1; totals cover the entire filter.\n\n## OnlyFans attribution boundary\n\nOnlyFans joins stored subscriber mappings and transactions; it does not publish native per-link revenue counters. Mapping starts at a fixed connection-relative boundary; older stored mappings can survive. A settled map is not proof of complete historical attribution. Preserve revenue.since and reconciliation. MYM/Fanvue use imported counter differences; the first reading is a baseline, and a first reading after the reporting start makes revenue a floor. Compare only matching currencies, scopes, date ranges and bases.\n\n### Current cadence and remaining work\n\n| Policy | Base interval | Additional jitter | Minimum private interval |\n| --- | --- | --- | --- |\n| OnlyFans | 10h | Up to 1.5h | 6h |\n| MYM | 12h | None | 12h (operational attempt floor) |\n| Fanvue | 2h; 1h on Agency | None | 1h |\n| Partner `stats_daily` (OnlyFans/MYM) | 24h | Up to 2h | Provider floor; profile restrictions remain |\n\nFanvue counters have a separate 6h clock, increased if the resolved refresh\ninterval is slower. Cadence is scheduling policy, not a completion SLA.\n`next_sync_at` is scheduled; failures, cooldowns, leases and processing can delay\ncompletion. Expired or invalid private grants resolve to ordinary policy.\nDefault service has not been moved to 24h globally.\n\nPrivate access never adds datasets or revenue permission. The admin writer,\naudit/concurrency workflow and grant/revoke UI remain unfinished; no REST/MCP\ntool provisions private offers. Fanvue serializers do not consistently receive\nthe current project plan, although scheduling does: do not infer the paid tier\nfrom the reported interval or promise hourly service from it.\n\n### Access and size troubleshooting\n\n- Missing tool or 403: check explicit `integrations.read`, current feature\n  eligibility, project membership, account rights and disconnected state.\n  `integrations.manage` alone does not grant reads. A refusal is not evidence\n  that the provider lacks revenue support.\n- Authorized connection but 404 resource: inspect `available_data` before\n  retrying. A provider may not support that record type.\n- 400: correct IDs, windows, date ranges or unsupported/duplicate arguments.\n- MCP 413: reduce statement `limit` and restart at page 0, reduce fan-health\n  `weeks`, or use REST for an oversized tracking reading. A single oversized\n  tracking row cannot be repaired by requesting fewer rows.\n- Metrics `transport_limited`: daily/breakdown rows were omitted to fit; totals\n  still cover the returned range. Narrow `days`, `top` or `breakdowns`, or use\n  REST. Do not recompute totals from partial arrays.\n- 429: respect the retry delay. An uncertain management response calls for a\n  status read before retrying; reading stale data never authorizes a sync."},{"name":"MCP","description":"Connect an MCP client to [https://dashboard.linkscale.to/api/mcp](https://dashboard.linkscale.to/api/mcp) using HTTP POST and an Authorization: Bearer header. Create an API key in the [dashboard](https://dashboard.linkscale.to/mcp), or use OAuth discovery at [protected-resource metadata](https://dashboard.linkscale.to/.well-known/oauth-protected-resource). Never put passwords, cookies or two-factor codes in chat.\n\n## Choose the right tool\n\n| Question | Tool | Permission |\n| --- | --- | --- |\n| Interpret integration data | get_data_sync_guide | integrations.read |\n| Discover accounts | list_data_sync_connections / get_data_sync_connection | integrations.read |\n| Platform tracking-link revenue, including unbound links | get_data_sync_tracking_links with connection_id | integrations.read |\n| Selected LinkScale-link revenue | get_data_sync_link_revenue with link_ids | statistics.read_link |\n| Daily totals / account summary | get_data_sync_metrics / get_data_sync_account | integrations.read |\n| Ledger / weekly retention | get_data_sync_statements / get_data_sync_fan_health | integrations.read |\n\nConnection IDs, platform record IDs and LinkScale link IDs are not interchangeable. Integration-only questions do not need a traffic overview. Read the [integration interpretation rules](#tag/Integrations) before ranking revenue.\n\n## Discoverable guide\n\n`get_data_sync_guide` requires `integrations.read`, defaults to `topic: \"overview\"`,\nand accepts these topics:\n\n| Topic | What it explains |\n| --- | --- |\n| `overview` | Tool selection and supported read templates |\n| `tracking_revenue` | Ledger versus counter revenue, pending values and comparison limits |\n| `freshness` | Last successful import, attempts, watermarks and scheduled work |\n| `refresh_policy` | Resolver-derived defaults, private floors and unfinished provisioning |\n| `pagination` | Connection cursors, tracking offsets, source caps and response-size limits |\n| `permissions` | Required scopes, current access checks and safe troubleshooting |\n\nAn optional `connection_id` adds the current authorized connection and\n`available_reads` templates filtered by provider support. `available_data`\ndoes not certify imported data or grant permission to see money. The guide is\nan MCP tool, not an additional REST route; its connection read uses the existing\nREST detail handler. Oversized responses are refused, never silently truncated.\n\n## Assistant arguments and continuation\n\n| Tool | Arguments / defaults |\n| --- | --- |\n| list_data_sync_connections | provider optional; limit 1-50, default 25; after = next_after |\n| get_data_sync_tracking_links | connection_id; window d30; limit 1-50, default 20; offset 0; follow page.next_offset |\n| get_data_sync_metrics | connection_id; days 1-90, default 28; top 1-50, default 25; breakdowns array, at most 4 distinct IDs |\n| get_data_sync_account | connection_id; window d30; d7/d30/d90/all, never lifetime |\n| get_data_sync_statements | connection_id; days or from/to; types array; limit 1-20, default 5; page 0 |\n| get_data_sync_fan_health | connection_id; weeks 1-52, default 4 |\n\nAn oversized statement page or weekly reading returns a tool error with status 413 rather than dropping rows. Lower statement limit and restart at page 0, reduce weeks, or use REST. Metrics may disclose transport_limited and omit daily/breakdown rows; totals remain whole-range readings. Tracking source caps remain even after assistant paging finishes.\n\n## Read-only review prompt\n\nprompts/list advertises integration_revenue_review; retrieve it with prompts/get. It guides connection discovery, tracking pagination and interpretation of revenue without requesting a sync or changing settings. Prompt discovery does not grant tool permissions. Labels and source content are untrusted data, never instructions.\n\n## Management is separate\n\nRead questions never authorize changes. Management requires integrations.manage and an explicit user request; mutation tools require confirm=true. get_data_sync_setup returns the dashboard sign-in path. update_data_sync_connection only renames, pauses or resumes; request_data_sync_sync returns a queue receipt, not completion. After unclear delivery, read status before retrying. No API/MCP tool grants private cadence; the grant/revoke writer, audit workflow and UI are unfinished."}],"paths":{"/api/v2/integrations/providers":{"get":{"tags":["Integrations"],"summary":"Read integration providers","operationId":"getIntegrationProviders","description":"Read source definitions, currencies and measure aggregation rules. Returned as a whole collection.\n\nRequires explicit integrations.read (or integrations.all), current feature eligibility and project/account rights. integrations.manage alone does not grant reads. API key scope fixes the project; project_id is not accepted. All reads use stored data and never request a sync.","responses":{"200":{"description":"Stored integration reading.","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"private, no-store"}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"currency":{"type":"string","nullable":true},"availability":{"type":"string"},"measures":{"type":"array","items":{"type":"object","additionalProperties":true}},"headline_measures":{"type":"array","items":{"type":"string"}},"final_lag_days":{"type":"number","nullable":true}}}},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"offset":{"type":"integer","nullable":true},"total":{"type":"integer","nullable":true},"returned":{"type":"integer"},"has_more":{"type":"boolean"},"next_cursor":{"type":"string","nullable":true}}},"meta":{"type":"object","properties":{"request_id":{"type":"string","nullable":true},"project_id":{"type":"string","nullable":true},"version":{"type":"integer","enum":[2]}},"required":["request_id","project_id","version"]}},"required":["success","data","pagination","meta"]}}}},"400":{"description":"Invalid, unknown or duplicate parameter; unsupported window or date range.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[false]},"error":{"type":"object","properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","method_not_allowed","validation_failed","invalid_json","rate_limited","internal_error"]},"message":{"type":"string"},"details":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}}},"nullable":true}},"required":["code","message","details"]},"meta":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/meta"}},"required":["success","error","meta"]}}}},"401":{"description":"Missing or invalid authentication.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"403":{"description":"Missing integrations.read, current feature/project/account access, or missing/disconnected/foreign connection. Resource existence is not disclosed.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"404":{"description":"Authorized connection does not support this resource; inspect available_data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"405":{"description":"Only GET is supported for integration reads.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"429":{"description":"Shared per-key rate limit reached. Respect Retry-After.","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until retry."}},"content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"500":{"description":"Unable to read stored integration data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}}}}},"/api/v2/integrations/connections":{"get":{"tags":["Integrations"],"summary":"Read integration connections","operationId":"getIntegrationConnections","description":"List scoped, connected accounts ordered by ID. Follow pagination.next_cursor as cursor, retaining provider. List total and offset are null.\n\nRequires explicit integrations.read (or integrations.all), current feature eligibility and project/account rights. integrations.manage alone does not grant reads. API key scope fixes the project; project_id is not accepted. All reads use stored data and never request a sync.","parameters":[{"name":"provider","in":"query","required":false,"description":"Registered provider ID, such as onlyfans, mym or fanvue.","schema":{"type":"string","pattern":"^[a-z0-9_]{1,80}$"}},{"name":"limit","in":"query","required":false,"description":"Maximum connections in this page.","schema":{"type":"integer","minimum":1,"maximum":50,"default":25}},{"name":"cursor","in":"query","required":false,"description":"Previous pagination.next_cursor.","schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"}}],"responses":{"200":{"description":"Stored integration reading.","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"private, no-store"}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"project_id":{"type":"string"},"provider":{"type":"string"},"label":{"type":"string","nullable":true},"status":{"type":"string"},"created_at":{"type":"string","format":"date-time","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true},"datasets":{"type":"array","items":{"type":"string"}},"health":{"type":"object","additionalProperties":true},"sync":{"type":"object","additionalProperties":true},"activity":{"type":"object","additionalProperties":true},"manual_sync":{"type":"object","additionalProperties":true},"available_data":{"type":"array","items":{"type":"string","enum":["metrics","tracking_links","account","statements","fan_health"]},"description":"Provider support only. Does not certify populated data or revenue authorization."},"freshness":{"type":"object","properties":{"last_synced_at":{"type":"string","format":"date-time","nullable":true,"description":"Last successful source pull, not response time. Null means no successful pull is recorded."},"age_seconds":{"type":"number","nullable":true},"data_through":{"type":"string","format":"date","nullable":true},"expected_through":{"type":"string","format":"date","nullable":true},"days_behind":{"type":"number","nullable":true},"state":{"type":"string","nullable":true},"last_attempt_at":{"type":"string","format":"date-time","nullable":true,"description":"Latest attempt; falls back to the last completed run on legacy records."},"next_sync_at":{"type":"string","format":"date-time","nullable":true,"description":"Scheduled time, not guaranteed completion."},"interval_hours":{"type":"number","nullable":true},"consecutive_failures":{"type":"number","nullable":true},"sync_activity":{"type":"string","nullable":true}}},"refresh_policy":{"type":"object","allOf":[{"type":"object","properties":{"source":{"type":"string","enum":["provider","subscription","sync_profile","private_offer"]},"interval_hours":{"type":"number","nullable":true},"standard_interval_hours":{"type":"number","nullable":true},"jitter_hours":{"type":"number","nullable":true},"max_scheduled_interval_hours":{"type":"number","nullable":true},"counter_interval_hours":{"type":"number","nullable":true},"minimum_private_interval_hours":{"type":"number","nullable":true},"minimum_attempt_interval_hours":{"type":"number","nullable":true},"access_state":{"type":"string","enum":["standard","active","expired","invalid"]},"offer_label":{"type":"string","nullable":true},"expires_at":{"type":"string","format":"date-time","nullable":true}},"description":"Resolved scheduling policy. Private offer provisioning is unfinished. Fanvue serializers do not consistently receive current project-plan context; use next_sync_at as stored scheduling evidence, not proof of a paid tier."}],"nullable":true}},"required":["id","provider","available_data","freshness"]}},"pagination":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/pagination"},"meta":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/meta"}},"required":["success","data","pagination","meta"]}}}},"400":{"description":"Invalid, unknown or duplicate parameter; unsupported window or date range.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"401":{"description":"Missing or invalid authentication.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"403":{"description":"Missing integrations.read, current feature/project/account access, or missing/disconnected/foreign connection. Resource existence is not disclosed.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"404":{"description":"Authorized connection does not support this resource; inspect available_data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"405":{"description":"Only GET is supported for integration reads.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"429":{"description":"Shared per-key rate limit reached. Respect Retry-After.","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until retry."}},"content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"500":{"description":"Unable to read stored integration data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}}}}},"/api/v2/integrations/connections/{connection_id}":{"get":{"tags":["Integrations"],"summary":"Read integration connection","operationId":"getIntegrationConnection","description":"Read current scoped status, source freshness, refresh policy, activity and manual-sync receipt. No source refresh is triggered.\n\nRequires explicit integrations.read (or integrations.all), current feature eligibility and project/account rights. integrations.manage alone does not grant reads. API key scope fixes the project; project_id is not accepted. All reads use stored data and never request a sync.","parameters":[{"name":"connection_id","in":"path","required":true,"description":"Connection ID returned by the connection list. Not a LinkScale link ID.","schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"}}],"responses":{"200":{"description":"Stored integration reading.","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"private, no-store"}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","properties":{"connection":{"$ref":"#/paths/~1api~1v2~1integrations~1connections/get/responses/200/content/application~1json/schema/properties/data/items"},"observed_at":{"type":"string","format":"date-time","nullable":true}}},"pagination":{"type":"object","nullable":true,"description":"Null for single-resource responses."},"meta":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/meta"}},"required":["success","data","pagination","meta"]}}}},"400":{"description":"Invalid, unknown or duplicate parameter; unsupported window or date range.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"401":{"description":"Missing or invalid authentication.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"403":{"description":"Missing integrations.read, current feature/project/account access, or missing/disconnected/foreign connection. Resource existence is not disclosed.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"404":{"description":"Authorized connection does not support this resource; inspect available_data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"405":{"description":"Only GET is supported for integration reads.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"429":{"description":"Shared per-key rate limit reached. Respect Retry-After.","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until retry."}},"content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"500":{"description":"Unable to read stored integration data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}}}}},"/api/v2/integrations/connections/{connection_id}/metrics":{"get":{"tags":["Integrations"],"summary":"Read integration metrics","operationId":"getIntegrationMetrics","description":"Read stored daily totals and optional breakdowns. The returned range ends at the stored watermark or publication-lag boundary, not necessarily today. Respect sum/last/weighted/derived measure rules. REST does not apply assistant response trimming.\n\nRequires explicit integrations.read (or integrations.all), current feature eligibility and project/account rights. integrations.manage alone does not grant reads. API key scope fixes the project; project_id is not accepted. All reads use stored data and never request a sync.","parameters":[{"name":"connection_id","in":"path","required":true,"description":"Connection ID returned by the connection list. Not a LinkScale link ID.","schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"}},{"name":"days","in":"query","required":false,"description":"Reporting days.","schema":{"type":"integer","minimum":1,"maximum":90,"default":28}},{"name":"top","in":"query","required":false,"description":"Maximum rows per breakdown.","schema":{"type":"integer","minimum":1,"maximum":50,"default":25}},{"name":"breakdowns","in":"query","required":false,"description":"Up to four distinct comma-separated dataset IDs from meta.available_breakdowns.","schema":{"type":"string"}}],"responses":{"200":{"description":"Stored integration reading.","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"private, no-store"}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","properties":{"connection":{"$ref":"#/paths/~1api~1v2~1integrations~1connections/get/responses/200/content/application~1json/schema/properties/data/items"},"provider":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/data/items"},"range":{"type":"object","properties":{"from":{"type":"string","format":"date"},"to":{"type":"string","format":"date"}},"required":["from","to"]},"totals":{"type":"object","additionalProperties":true},"series":{"type":"array","items":{"type":"object","additionalProperties":true}},"breakdowns":{"type":"array","items":{"type":"object","additionalProperties":true}},"meta":{"type":"object","additionalProperties":true,"description":"Available breakdowns and source coverage. Respect measure aggregation definitions."},"observed_at":{"type":"string","format":"date-time","description":"When LinkScale served stored data. Not a source refresh."}}},"pagination":{"type":"object","nullable":true,"description":"Null for single-resource responses."},"meta":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/meta"}},"required":["success","data","pagination","meta"]}}}},"400":{"description":"Invalid, unknown or duplicate parameter; unsupported window or date range.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"401":{"description":"Missing or invalid authentication.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"403":{"description":"Missing integrations.read, current feature/project/account access, or missing/disconnected/foreign connection. Resource existence is not disclosed.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"404":{"description":"Authorized connection does not support this resource; inspect available_data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"405":{"description":"Only GET is supported for integration reads.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"429":{"description":"Shared per-key rate limit reached. Respect Retry-After.","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until retry."}},"content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"500":{"description":"Unable to read stored integration data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}}}}},"/api/v2/integrations/connections/{connection_id}/tracking-links":{"get":{"tags":["Integrations"],"summary":"Read integration tracking links","operationId":"getIntegrationTrackingLinks","description":"Read platform tracking links, including links without a LinkScale binding. revenue_reading harmonizes OnlyFans ledger joins and MYM/Fanvue counters. OnlyFans lifetime selection means stored_history. Unknown/pending amounts stay null; measured zero stays zero. Sources without tracking history retain a 300-record cap disclosed by coverage.has_more. Counter totals cover returned records; ledger reconciliation can cover the whole stored ledger. No REST offset parameter exists for this source reading.\n\nRequires explicit integrations.read (or integrations.all), current feature eligibility and project/account rights. integrations.manage alone does not grant reads. API key scope fixes the project; project_id is not accepted. All reads use stored data and never request a sync.","parameters":[{"name":"connection_id","in":"path","required":true,"description":"Connection ID returned by the connection list. Not a LinkScale link ID.","schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"}},{"name":"window","in":"query","required":false,"description":"Choose a window supported by the source; inspect response.windows.","schema":{"type":"string","enum":["d7","d30","d90","all","lifetime"],"default":"d30"}}],"responses":{"200":{"description":"Stored integration reading.","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"private, no-store"}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","properties":{"connection":{"$ref":"#/paths/~1api~1v2~1integrations~1connections/get/responses/200/content/application~1json/schema/properties/data/items"},"provider":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/data/items"},"window":{"type":"string"},"windows":{"type":"array","items":{"type":"string"}},"range":{"$ref":"#/paths/~1api~1v2~1integrations~1connections~1{connection_id}~1metrics/get/responses/200/content/application~1json/schema/properties/data/properties/range"},"counters":{"type":"array","items":{"type":"string"}},"coverage":{"type":"object","properties":{"returned":{"type":"integer"},"limit":{"type":"integer","nullable":true},"has_more":{"type":"boolean","description":"More source records exist beyond the source cap. MCP offsets cannot recover them."},"totals_scope":{"type":"string","enum":["returned_records"]}}},"tracking_links":{"type":"array","items":{"type":"object","properties":{"record_id":{"type":"string"},"code":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"},"created_at":{"type":"string","format":"date-time","nullable":true},"deleted":{"type":"boolean"},"shared":{"type":"number","nullable":true},"clicks":{"type":"number","nullable":true},"subscribers":{"type":"number","nullable":true},"earnings":{"type":"number","nullable":true},"tracked_earnings":{"type":"number","nullable":true},"conversion_rate_pct":{"type":"number","nullable":true},"visits":{"type":"number","nullable":true},"since":{"type":"string","format":"date-time","nullable":true},"last_reading_at":{"type":"string","format":"date-time","nullable":true},"pending":{"type":"boolean"},"revenue_reading":{"type":"object","properties":{"amount":{"type":"number","nullable":true,"description":"Stored money reading in the declared currency. Null means pending or unavailable, never zero."},"currency":{"type":"string","nullable":true},"basis":{"type":"string","enum":["ledger","counter",null],"nullable":true},"scope":{"type":"string","enum":["window","lifetime","stored_history"]},"range":{"type":"object","properties":{"from":{"type":"string","nullable":true},"to":{"type":"string","nullable":true}},"nullable":true},"status":{"type":"string","enum":["available","pending","unavailable"]},"reason":{"type":"string","nullable":true},"floor":{"type":"boolean","nullable":true,"description":"True means partial reading; false does not prove complete historical attribution."}},"required":["amount","currency","basis","scope","range","status","reason","floor"]},"lifetime":{"type":"object","additionalProperties":true,"nullable":true},"link":{"type":"object","additionalProperties":true,"nullable":true},"binding":{"type":"object","additionalProperties":true},"revenue":{"type":"object","additionalProperties":true,"nullable":true,"description":"Provider-specific ledger details, pending reasons, fixed-start amounts and recovery state. Redacted when unauthorized."},"sources":{"type":"object","additionalProperties":true,"nullable":true,"description":"Fanvue source totals, breakdown and reconciliation when available."}},"required":["record_id","revenue_reading"]}},"totals":{"type":"object","additionalProperties":true},"account_lifetime":{"type":"object","additionalProperties":true,"nullable":true},"revenue":{"type":"object","additionalProperties":true,"nullable":true},"fan_join":{"type":"object","additionalProperties":true,"nullable":true},"subscriber_split":{"type":"object","additionalProperties":true,"nullable":true},"observed_at":{"type":"string","format":"date-time","description":"When LinkScale served stored data. Not a source refresh."}}},"pagination":{"type":"object","nullable":true,"description":"Null for single-resource responses."},"meta":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/meta"}},"required":["success","data","pagination","meta"]}}}},"400":{"description":"Invalid, unknown or duplicate parameter; unsupported window or date range.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"401":{"description":"Missing or invalid authentication.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"403":{"description":"Missing integrations.read, current feature/project/account access, or missing/disconnected/foreign connection. Resource existence is not disclosed.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"404":{"description":"Authorized connection does not support this resource; inspect available_data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"405":{"description":"Only GET is supported for integration reads.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"429":{"description":"Shared per-key rate limit reached. Respect Retry-After.","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until retry."}},"content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"500":{"description":"Unable to read stored integration data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}}}}},"/api/v2/integrations/connections/{connection_id}/account":{"get":{"tags":["Integrations"],"summary":"Read integration account","operationId":"getIntegrationAccount","description":"Read stored audience and account revenue summary. Use the actual returned range. Lifetime is not an accepted account window. Restricted revenue is redacted.\n\nRequires explicit integrations.read (or integrations.all), current feature eligibility and project/account rights. integrations.manage alone does not grant reads. API key scope fixes the project; project_id is not accepted. All reads use stored data and never request a sync.","parameters":[{"name":"connection_id","in":"path","required":true,"description":"Connection ID returned by the connection list. Not a LinkScale link ID.","schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"}},{"name":"window","in":"query","required":false,"description":"Choose a window supported by the source.","schema":{"type":"string","enum":["d7","d30","d90","all"],"default":"d30"}}],"responses":{"200":{"description":"Stored integration reading.","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"private, no-store"}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","properties":{"connection":{"$ref":"#/paths/~1api~1v2~1integrations~1connections/get/responses/200/content/application~1json/schema/properties/data/items"},"provider":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/data/items"},"window":{"type":"string"},"windows":{"type":"array","items":{"type":"string"}},"range":{"$ref":"#/paths/~1api~1v2~1integrations~1connections~1{connection_id}~1metrics/get/responses/200/content/application~1json/schema/properties/data/properties/range"},"account":{"type":"object","properties":{"currency":{"type":"string"},"subscription_price":{"type":"number","nullable":true},"since":{"type":"string","format":"date-time","nullable":true},"pending":{"type":"boolean"},"current":{"type":"object","additionalProperties":true},"revenue":{"type":"object","additionalProperties":true},"audience":{"type":"object","additionalProperties":true},"binding":{"type":"object","additionalProperties":true}}},"link":{"type":"object","additionalProperties":true,"nullable":true},"observed_at":{"type":"string","format":"date-time","description":"When LinkScale served stored data. Not a source refresh."}}},"pagination":{"type":"object","nullable":true,"description":"Null for single-resource responses."},"meta":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/meta"}},"required":["success","data","pagination","meta"]}}}},"400":{"description":"Invalid, unknown or duplicate parameter; unsupported window or date range.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"401":{"description":"Missing or invalid authentication.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"403":{"description":"Missing integrations.read, current feature/project/account access, or missing/disconnected/foreign connection. Resource existence is not disclosed.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"404":{"description":"Authorized connection does not support this resource; inspect available_data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"405":{"description":"Only GET is supported for integration reads.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"429":{"description":"Shared per-key rate limit reached. Respect Retry-After.","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until retry."}},"content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"500":{"description":"Unable to read stored integration data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}}}}},"/api/v2/integrations/connections/{connection_id}/statements":{"get":{"tags":["Integrations"],"summary":"Read integration statements","operationId":"getIntegrationStatements","description":"Read imported ledger rows without fan identities. Use days (default 28), or from/to; days and from cannot be combined. to is optional and defaults to the stored watermark or publication-lag boundary. Unsupported dates/ranges are refused instead of silently clamped. Keep limit/filter fixed; advance page + 1 while data.page.has_more. Totals and by_type cover the entire filter, never only the page. Revenue access is required.\n\nRequires explicit integrations.read (or integrations.all), current feature eligibility and project/account rights. integrations.manage alone does not grant reads. API key scope fixes the project; project_id is not accepted. All reads use stored data and never request a sync.","parameters":[{"name":"connection_id","in":"path","required":true,"description":"Connection ID returned by the connection list. Not a LinkScale link ID.","schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"}},{"name":"days","in":"query","required":false,"description":"Reporting days; omit when using from.","schema":{"type":"integer","minimum":1,"maximum":400,"default":28}},{"name":"from","in":"query","required":false,"description":"Inclusive first day.","schema":{"type":"string","format":"date"}},{"name":"to","in":"query","required":false,"description":"Inclusive last day.","schema":{"type":"string","format":"date"}},{"name":"types","in":"query","required":false,"description":"Distinct comma-separated values: subscription, tip, message, post, stream, referral, chargeback, other.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Fixed page size.","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"page","in":"query","required":false,"description":"Zero-based page index.","schema":{"type":"integer","minimum":0,"maximum":10000,"default":0}}],"responses":{"200":{"description":"Stored integration reading.","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"private, no-store"}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","properties":{"connection":{"$ref":"#/paths/~1api~1v2~1integrations~1connections/get/responses/200/content/application~1json/schema/properties/data/items"},"provider":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/data/items"},"range":{"$ref":"#/paths/~1api~1v2~1integrations~1connections~1{connection_id}~1metrics/get/responses/200/content/application~1json/schema/properties/data/properties/range"},"types":{"type":"array","items":{"type":"string"}},"available_types":{"type":"array","items":{"type":"string"}},"totals":{"type":"object","additionalProperties":true,"nullable":true},"by_type":{"type":"array","items":{"type":"object","additionalProperties":true}},"statements":{"type":"array","items":{"type":"object","properties":{"record_id":{"type":"string"},"date":{"type":"string","format":"date"},"ts":{"type":"string","format":"date-time","nullable":true},"type":{"type":"string"},"description":{"type":"string"},"gross":{"type":"number","nullable":true},"fee":{"type":"number","nullable":true},"net":{"type":"number","nullable":true},"status":{"type":"string"},"currency":{"type":"string"}}}},"page":{"type":"object","properties":{"index":{"type":"integer"},"limit":{"type":"integer"},"total":{"type":"integer"},"returned":{"type":"integer"},"has_more":{"type":"boolean"}}},"observed_at":{"type":"string","format":"date-time","description":"When LinkScale served stored data. Not a source refresh."}}},"pagination":{"type":"object","nullable":true,"description":"Null for single-resource responses."},"meta":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/meta"}},"required":["success","data","pagination","meta"]}}}},"400":{"description":"Invalid, unknown or duplicate parameter; unsupported window or date range.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"401":{"description":"Missing or invalid authentication.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"403":{"description":"Missing integrations.read, current feature/project/account access, or missing/disconnected/foreign connection. Resource existence is not disclosed.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"404":{"description":"Authorized connection does not support this resource; inspect available_data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"405":{"description":"Only GET is supported for integration reads.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"429":{"description":"Shared per-key rate limit reached. Respect Retry-After.","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until retry."}},"content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"500":{"description":"Unable to read stored integration data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}}}}},"/api/v2/integrations/connections/{connection_id}/fan-health":{"get":{"tags":["Integrations"],"summary":"Read integration fan health","operationId":"getIntegrationFanHealth","description":"Read weekly fan-health records oldest first, using the platform calculations. Never sum weekly rates, lifetime values or retention measures across weeks. Inspect available_data before calling.\n\nRequires explicit integrations.read (or integrations.all), current feature eligibility and project/account rights. integrations.manage alone does not grant reads. API key scope fixes the project; project_id is not accepted. All reads use stored data and never request a sync.","parameters":[{"name":"connection_id","in":"path","required":true,"description":"Connection ID returned by the connection list. Not a LinkScale link ID.","schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"}},{"name":"weeks","in":"query","required":false,"description":"Maximum weekly records.","schema":{"type":"integer","minimum":1,"maximum":52,"default":12}}],"responses":{"200":{"description":"Stored integration reading.","headers":{"Cache-Control":{"schema":{"type":"string"},"description":"private, no-store"}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","properties":{"connection":{"$ref":"#/paths/~1api~1v2~1integrations~1connections/get/responses/200/content/application~1json/schema/properties/data/items"},"provider":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/data/items"},"weeks":{"type":"array","items":{"type":"object","additionalProperties":true}},"trend":{"type":"object","additionalProperties":true,"nullable":true},"unsubscribe_reasons":{"type":"array","items":{"type":"string","enum":["too_expensive","not_enough_content","content_not_expected","creator_not_active","other"]}},"observed_at":{"type":"string","format":"date-time","description":"When LinkScale served stored data. Not a source refresh."}}},"pagination":{"type":"object","nullable":true,"description":"Null for single-resource responses."},"meta":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/200/content/application~1json/schema/properties/meta"}},"required":["success","data","pagination","meta"]}}}},"400":{"description":"Invalid, unknown or duplicate parameter; unsupported window or date range.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"401":{"description":"Missing or invalid authentication.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"403":{"description":"Missing integrations.read, current feature/project/account access, or missing/disconnected/foreign connection. Resource existence is not disclosed.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"404":{"description":"Authorized connection does not support this resource; inspect available_data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"405":{"description":"Only GET is supported for integration reads.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"429":{"description":"Shared per-key rate limit reached. Respect Retry-After.","headers":{"Retry-After":{"schema":{"type":"string"},"description":"Seconds until retry."}},"content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}},"500":{"description":"Unable to read stored integration data.","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v2~1integrations~1providers/get/responses/400/content/application~1json/schema"}}}}}}},"/api/mcp":{"post":{"tags":["MCP"],"summary":"Discover and call the project-scoped assistant tools","operationId":"callMcp","description":"JSON-RPC 2.0 over HTTP POST. Use a project API key or authorized OAuth token. Initialize, discover tools/list, then choose tools based on the question. Integration-only reads do not require describe_project or unrelated traffic scopes. Inspect result.isError and JSON-RPC error even when HTTP is 200. A tool refusal may report status 413 in tool content without HTTP 413. GET is not an SSE stream and returns 405.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"jsonrpc":{"type":"string","enum":["2.0"]},"id":{"oneOf":[{"type":"string"},{"type":"integer"}]},"method":{"type":"string"},"params":{"type":"object","additionalProperties":true}},"required":["jsonrpc","method"]},"examples":{"initialize":{"value":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"integration-client","version":"1.0.0"}}}},"tools":{"value":{"jsonrpc":"2.0","id":2,"method":"tools/list"}},"guide":{"value":{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_data_sync_guide","arguments":{"topic":"tracking_revenue"}}}},"connections":{"value":{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_data_sync_connections","arguments":{"provider":"onlyfans","limit":10}}}},"tracking":{"value":{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_data_sync_tracking_links","arguments":{"connection_id":"0123456789abcdef01234567","window":"d30","offset":0,"limit":20}}}},"review":{"value":{"jsonrpc":"2.0","id":6,"method":"prompts/get","params":{"name":"integration_revenue_review"}}}}}}},"responses":{"200":{"description":"JSON-RPC result or error. Tool text content contains JSON data; inspect isError before parsing a successful reading.","content":{"application/json":{"schema":{"type":"object","properties":{"jsonrpc":{"type":"string"},"id":{"oneOf":[{"type":"string"},{"type":"integer"}]},"result":{"type":"object","additionalProperties":true},"error":{"type":"object","additionalProperties":true}}}}}},"204":{"description":"Notification acknowledged without a response body."},"401":{"description":"Authentication required or invalid. Inspect WWW-Authenticate for OAuth metadata."},"403":{"description":"Caller access refused."},"429":{"description":"Transport rate limit; respect Retry-After."}}}},"/api/v1/links":{"put":{"tags":["Links"],"summary":"Create a new link","description":"Create a new shortened link with optional customization options.\n\nThe link is identical to one created in the dashboard — same landing-page,\nShield, template, Geo Filter and Privacy capabilities, and it stays fully\neditable in the dashboard afterwards. It is stored with\n`created_by_api: true` and belongs to the **project** rather than to an\nindividual team member. See *Links created through the API* in the\nintroduction.\n","operationId":"createLink","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLinkRequest"},"examples":{"basic_landing_page":{"summary":"Create basic landing page","value":{"type":"l_p","u":"johndoe","domain":"link.dm","n":"John Doe","bio":"Software Developer"}},"direct_link":{"summary":"Create direct link","value":{"type":"d_l","u":"mylink","domain":"link.dm","url":"https://example.com"}},"with_template_and_dynamic_info":{"summary":"Use template with dynamic informations override","value":{"type":"l_p","u":"team-member-1","domain":"link.dm","cs_template":"507f1f77bcf86cd799439011","dynamic_informations":{"enabled":true,"pp_enabled":true,"n":"Alice Johnson","pp":{"url":"https://cdn.example.com/alice.jpg","enabled":true,"size":160,"border":{"color":"#FF6B6B","style":"solid","width":4}}}}},"with_geo_filters":{"summary":"Create a link with Geo Filter rules","description":"Blocks France outright, sends US/Canada traffic to a regional\npage, and serves French speakers everywhere else a localized\ndestination. Everyone else gets the link's own `url`.\n\nRule order matters only for ties — the IP rules here outrank the\nbrowser-language one regardless of position.\n","value":{"type":"d_l","u":"worldwide","domain":"link.dm","url":"https://example.com","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"}]}},"geo_filter_city_targeting":{"summary":"Geo Filter narrowed to regions and cities","description":"A rule that carries `regions` / `cities` only matches visitors in\nthose places — and it outranks any country-wide rule on the same\nlink.\n","value":{"type":"d_l","u":"paris-only","domain":"link.dm","url":"https://example.com","geo_rules":[{"detection_type":"ip","location":"FR","regions":["Ile-de-France"],"cities":["Paris"],"t":"d_l","url":"https://example.com/paris"}]}},"geo_filter_landing_template":{"summary":"Geo Filter serving a project template","description":"A `t: \"l_p\"` rule serves a landing page instead of redirecting.\nThe template is resolved at serve time, so editing it updates\nthe geo rule immediately.\n","value":{"type":"l_p","u":"regional","domain":"link.dm","geo_rules":[{"detection_type":"ip","location":"EUROPE","t":"l_p","cs_template":"507f1f77bcf86cd799439011"}]}},"private_landing_page":{"summary":"Create a private landing page","description":"The new page is served with its destinations cloaked behind\n`https://<your-domain>/go/<token>`, and this call provisions\nthose redirects. See the **Link Privacy** section.\n","value":{"type":"l_p","u":"launch","domain":"link.dm","n":"John Doe","privacy":{"links":true}}}}}}},"responses":{"201":{"description":"Link created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkResponse"}}}},"400":{"description":"Bad request - Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"missing_api_key":{"summary":"Missing API key","value":{"success":false,"error":"Unauthorized","status":401,"message":"API key required. Please provide a valid API key in the Authorization header with format: Bearer lk_xxxxx","timestamp":"2025-10-08T10:30:00.000Z"}},"invalid_format":{"summary":"Invalid API key format","value":{"success":false,"error":"Unauthorized","status":401,"message":"Invalid API key format. API keys must start with 'lk_'","timestamp":"2025-10-08T10:30:00.000Z"}}}}}},"405":{"description":"Method not allowed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MethodNotAllowedError"},"examples":{"post_not_allowed":{"summary":"POST method not allowed","value":{"success":false,"error":"Method not allowed","status":405,"message":"HTTP method 'POST' is not supported for this endpoint","allowedMethods":["GET","PUT"],"attemptedMethod":"POST","timestamp":"2025-10-08T10:30:00.000Z","hint":"Try using one of these methods: GET, PUT"}}}}}},"429":{"description":"Too many requests","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Links"],"summary":"List links","description":"Retrieve a paginated list of your links with optional filtering","operationId":"listLinks","parameters":[{"name":"page","in":"query","description":"Page number for pagination","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","description":"Number of links per page","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"folder_id","in":"query","description":"Return only the links inside this folder. Ids come from\n`GET /api/v1/folders`. A well-formed id that matches no folder simply\nreturns an empty page rather than an error.\n","required":false,"schema":{"type":"string"},"example":"6a7491caaa4bce8130ea5a62"},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string"}},{"name":"search","in":"query","description":"Search in title and description","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Links retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinksListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_api_key":{"summary":"Invalid API key","value":{"success":false,"error":{"code":"INVALID_API_KEY","message":"Invalid or missing API key"}}}}}}},"429":{"description":"Too many requests","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"rate_limit":{"summary":"Rate limit exceeded","value":{"success":false,"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Rate limit exceeded. Please try again later."}}}}}}}}}},"/api/v1/links/{id}":{"get":{"tags":["Links"],"summary":"Get link details","description":"Retrieve detailed information about a specific link","operationId":"getLinkDetails","parameters":[{"name":"id","in":"path","required":true,"description":"The unique identifier of the link","schema":{"type":"string"}}],"responses":{"200":{"description":"Link details retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkDetailsResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_api_key":{"summary":"Invalid API key","value":{"success":false,"error":{"code":"INVALID_API_KEY","message":"Invalid or missing API key"}}}}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"link_not_found":{"summary":"Link not found","value":{"success":false,"error":{"code":"LINK_NOT_FOUND","message":"The requested link was not found"}}}}}}},"405":{"description":"Method not allowed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MethodNotAllowedError"},"examples":{"post_not_allowed":{"summary":"POST method not allowed","value":{"success":false,"error":"Method not allowed","status":405,"message":"HTTP method 'POST' is not supported for this endpoint","allowedMethods":["GET","PATCH","DELETE"],"attemptedMethod":"POST","timestamp":"2025-10-08T10:30:00.000Z","hint":"Try using one of these methods: GET, PATCH, DELETE"}}}}}}}},"patch":{"tags":["Links"],"summary":"Update link","description":"Update an existing link (partial update). Only provided fields will be updated.","operationId":"updateLink","parameters":[{"name":"id","in":"path","required":true,"description":"The unique identifier of the link","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateLinkRequest"},"examples":{"update_url":{"summary":"Update target URL","value":{"url":"https://new-example.com"}},"update_username":{"summary":"Update username","value":{"u":"newusername"}},"update_display_name":{"summary":"Update display name and bio","value":{"n":"New Name","bio":"Updated bio"}},"update_dynamic_informations":{"summary":"Override template name and profile picture","value":{"dynamic_informations":{"enabled":true,"pp_enabled":true,"n":"Sarah Johnson","pp":{"url":"https://cdn.example.com/sarah-profile.jpg","enabled":true,"size":180,"border":{"color":"#E94E77","style":"solid","width":4}}}}},"replace_geo_filters":{"summary":"Replace the Geo Filter rules","description":"`geo_rules` replaces the **entire** list — it is not merged. To\nadd one rule, read the current rules from\n`GET /api/v1/links/{id}` and send them back with the new one\nappended.\n","value":{"geo_rules":[{"detection_type":"ip","location":"LOW_GDP_PER_CAPITA","t":"block"},{"detection_type":"ip","countries":["DE","AT","CH"],"t":"d_l","url":"https://example.com/de"}]}},"disable_one_geo_rule":{"summary":"Keep a Geo Filter rule but stop it firing","description":"Set `enabled: false` rather than deleting the rule, so its\nconfiguration survives for later. A disabled rule is skipped\nbefore anything else is evaluated.\n","value":{"geo_rules":[{"id":"eu-visitors","enabled":false,"detection_type":"ip","location":"EUROPE","t":"d_l","url":"https://example.com/eu"}]}},"clear_geo_filters":{"summary":"Remove every Geo Filter rule","description":"Send an empty array. The field is unset on the link and\n`geo_rules_updated_at` is stamped with the time of the clear.\n","value":{"geo_rules":[]}},"make_link_private":{"summary":"Make the link private (hide its destinations)","description":"Every destination the link renders is served through\n`https://<your-domain>/go/<token>` from now on, so the real URL\nnever appears in the page a crawler reads. This call also\nprovisions those redirects — nothing else to switch on. Send\n`{\"links\": false}` to go back to normal.\n","value":{"privacy":{"links":true}}}}}}},"responses":{"200":{"description":"Link updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkResponse"},"examples":{"successful_update":{"summary":"Successful link update","value":{"success":true,"message":"Link updated successfully","data":{"id":"abc123def456","short_url":"https://link.dm/newusername","type":"l_p","updated_at":"2025-10-08T10:30:00.000Z"},"timestamp":"2025-10-08T10:30:00.000Z"}}}}}},"400":{"description":"Bad request - Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"},"examples":{"username_taken":{"summary":"Username already taken","value":{"success":false,"error":"Validation failed","status":400,"details":[{"field":"u","message":"Username is already taken"}],"timestamp":"2025-10-08T10:30:00.000Z"}},"reserved_username":{"summary":"Reserved username","value":{"success":false,"error":"Validation failed","status":400,"details":[{"field":"u","message":"Username 'privacy' is reserved and cannot be used"}],"timestamp":"2025-10-08T10:30:00.000Z"}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"link_not_found":{"summary":"Link not found","value":{"success":false,"error":"Link not found","status":404,"timestamp":"2025-10-08T10:30:00.000Z","hint":"Verify that the link ID is correct and belongs to your project"}}}}}},"405":{"description":"Method not allowed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MethodNotAllowedError"}}}}}},"delete":{"tags":["Links"],"summary":"Delete link","description":"Permanently delete a link. This action cannot be undone.","operationId":"deleteLink","parameters":[{"name":"id","in":"path","required":true,"description":"The unique identifier of the link","schema":{"type":"string"}}],"responses":{"200":{"description":"Link deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Link deleted successfully"},"data":{"type":"object","properties":{"id":{"type":"string","example":"abc123def456"},"deleted_at":{"type":"string","format":"date-time","example":"2025-10-08T10:30:00.000Z"}}},"timestamp":{"type":"string","format":"date-time","example":"2025-10-08T10:30:00.000Z"}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_api_key":{"summary":"Invalid API key","value":{"success":false,"error":{"code":"INVALID_API_KEY","message":"Invalid or missing API key"}}}}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"link_not_found":{"summary":"Link not found","value":{"success":false,"error":"Link not found","status":404,"timestamp":"2025-10-08T10:30:00.000Z","hint":"Verify that the link ID is correct and belongs to your project"}}}}}},"405":{"description":"Method not allowed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MethodNotAllowedError"}}}}}}},"/api/v1/templates":{"put":{"tags":["Templates"],"summary":"Create a new template","description":"Create a new template for the authenticated project with customization\noptions.\n\n**Landing templates only.** Writing to the `first_step` and `three_dots`\nfamilies is not exposed — those are authored in the dashboard, and the API\nreads them (`GET /api/v1/templates?kind=...`) so you can attach them to a\nlink.\n","operationId":"createTemplate","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTemplateRequest"}}}},"responses":{"200":{"description":"Template created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplateResponse"}}}},"400":{"description":"Bad request - Validation error or template limit reached","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"template_limit":{"summary":"Template limit reached","value":{"success":false,"error":{"code":"TEMPLATE_LIMIT_REACHED","message":"Template limit reached. Free users can create up to 1 template."}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Failed to create template","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Templates"],"summary":"Get all templates","description":"Retrieve the templates of one **family** for the authenticated project.\n\nLinkScale keeps three separate template families — `landing`,\n`first_step` and `three_dots` — and `kind` picks which one to list. See\n*Templates — the three families* in the introduction for what each skins\nand how to attach it to a link.\n\nIds are unique **per family**, so an id returned here is only meaningful\ntogether with the `kind` it came from.\n","operationId":"listTemplates","parameters":[{"name":"kind","in":"query","required":false,"description":"Template family to list. Defaults to `landing`, which is what this\nendpoint returned before the families were exposed.\n","schema":{"type":"string","enum":["landing","first_step","three_dots"],"default":"landing"},"example":"three_dots"},{"name":"summary","in":"query","required":false,"description":"Return a trimmed row per template — `_id`, name, `template_kind`,\n`folder_id` and timestamps — instead of the full document. Recommended\nfor discovery: a v2 template embeds an entire page, so the default\nfull listing can be several megabytes.\n","schema":{"type":"boolean","default":false},"example":true}],"responses":{"200":{"description":"Templates retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplatesListResponse"}}}},"400":{"description":"Bad request - Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/templates/{template_id}":{"get":{"tags":["Templates"],"summary":"Get template by ID","description":"Retrieve a specific template by ID from the authenticated project.\n\nEach template family is a separate collection, so `kind` must match the\nfamily the id belongs to. A first-step or 3-dots id read without its\n`kind` simply misses and returns `404`.\n","operationId":"getTemplateById","parameters":[{"name":"template_id","in":"path","required":true,"description":"The unique identifier of the template","schema":{"type":"string"},"example":"507f1f77bcf86cd799439011"},{"name":"kind","in":"query","required":false,"description":"Family the id belongs to. Defaults to `landing`.\n","schema":{"type":"string","enum":["landing","first_step","three_dots"],"default":"landing"},"example":"first_step"}],"responses":{"200":{"description":"Template retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplateDetailsResponse"}}}},"400":{"description":"Bad request - Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Template not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"template_not_found":{"summary":"Template not found","value":{"success":false,"error":{"code":"TEMPLATE_NOT_FOUND","message":"Template not found"}}}}}}}}},"patch":{"tags":["Templates"],"summary":"Update template","description":"Update a specific template from the authenticated project. Only provided\nfields will be updated.\n\n**Landing templates only** — `kind` is not accepted here.\n","operationId":"updateTemplate","parameters":[{"name":"template_id","in":"path","required":true,"description":"The unique identifier of the template","schema":{"type":"string"},"example":"507f1f77bcf86cd799439011"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTemplateRequest"},"examples":{"update_name":{"summary":"Update template name","value":{"t_name":"New Template Name"}},"update_bio":{"summary":"Update display name and bio","value":{"n":"New Name","bio":"Updated bio"}}}}}},"responses":{"200":{"description":"Template updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplateUpdateResponse"}}}},"400":{"description":"Bad request - Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Template not found or doesn't belong to this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Failed to update template","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Templates"],"summary":"Delete template","description":"Permanently delete a template from the authenticated project. This action\ncannot be undone.\n\n**Landing templates only** — `kind` is not accepted here.\n","operationId":"deleteTemplate","parameters":[{"name":"template_id","in":"path","required":true,"description":"The unique identifier of the template","schema":{"type":"string"},"example":"507f1f77bcf86cd799439011"}],"responses":{"200":{"description":"Template deleted successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplateDeleteResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Template not found or doesn't belong to this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Failed to delete template","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/links/{link_id}/landing":{"get":{"tags":["Landing Pages"],"summary":"Read a link's landing page","description":"Return a link's Landing v2 configuration: the resolved page (draft or published), its lifecycle metadata, which customization model it uses, the Model B dynamic overrides (when present), and a live preview_url.","operationId":"getLinkLanding","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link.","schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"},{"name":"state","in":"query","required":false,"description":"Which stored copy to return.","schema":{"type":"string","enum":["published","draft"],"default":"published"}},{"name":"format","in":"query","required":false,"description":"Response format. json (default) returns the page JSON. html is not supported (returns 501) - to view the rendered page, open the link's live preview_url.","schema":{"type":"string","enum":["json","html"],"default":"json"}}],"responses":{"200":{"description":"Landing configuration retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingReadResponse"}}}},"400":{"description":"Invalid link id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"501":{"description":"HTML render not supported - use the link's preview_url.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}},"put":{"tags":["Landing Pages"],"summary":"Replace a link's landing page","description":"Replace the whole page. Runs the exact pipeline the dashboard editor uses (structural validation, href normalization, a 2 MB cap, a version snapshot, and the mandatory edge re-mirror). For a link, a write is publish + activate in one shot - the page goes live immediately.","operationId":"replaceLinkLanding","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link.","schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteLandingRequest"},"examples":{"minimal":{"summary":"A minimal page","value":{"page":{"version":2,"theme":{"color_primary":"#2563eb"},"sections":[{"id":"sec_1","type":"hero","layout":"centered","slots":{}}]}}}}}}},"responses":{"200":{"description":"Page written and published successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteLandingResponse"}}}},"400":{"description":"Missing page or structurally invalid page (over 500 sections, nesting deeper than one level, or a section with no string type).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"413":{"description":"Serialized page over 2 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}},"patch":{"tags":["Landing Pages"],"summary":"Patch a link's landing page","description":"Targeted merge - send only the top-level page keys you want to change. theme and meta are merged one level deep; sections, platform_groups, and version are replaced wholesale (arrays are never element-merged). PATCH amends the currently published page and is not a create: it returns 409 if the resource has no Landing v2 page yet (use PUT first).","operationId":"patchLinkLanding","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link.","schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteLandingRequest"},"examples":{"recolor":{"summary":"Recolor without touching the sections","value":{"page":{"theme":{"color_primary":"#1d4ed8"}}}}}}}},"responses":{"200":{"description":"Page patched successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteLandingResponse"}}}},"400":{"description":"Missing page or structurally invalid page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"409":{"description":"No existing landing to patch (use PUT first).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"413":{"description":"Serialized page over 2 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}}},"/api/v1/links/{link_id}/landing/history":{"get":{"tags":["Landing Pages"],"summary":"List a link's landing version history","description":"Every write snapshots the page (rolling window of the last 60, newest first). Add ?include_pages=false for a lightweight metadata list. There is no restore endpoint - roll back by reading an old version and PUT-ting its page back.","operationId":"getLinkLandingHistory","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link.","schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"},{"name":"include_pages","in":"query","required":false,"description":"Set to false to omit each version's page (metadata only).","schema":{"type":"boolean","default":true}}],"responses":{"200":{"description":"Version list retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingHistoryListResponse"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}}},"/api/v1/links/{link_id}/landing/history/{version_id}":{"get":{"tags":["Landing Pages"],"summary":"Read one landing version","description":"Return a single history snapshot with its full, un-folded page.","operationId":"getLinkLandingVersion","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link.","schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"},{"name":"version_id","in":"path","required":true,"description":"The snapshot id (from the history list).","schema":{"type":"string"},"example":"6650c3aa11cc22dd33ee44ff"}],"responses":{"200":{"description":"Version retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingHistoryVersionResponse"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Version not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}}},"/api/v1/links/{link_id}/dynamic-overrides":{"get":{"tags":["Landing Pages"],"summary":"Read a link's dynamic overrides (Model B)","description":"Read the Model B per-link override: the shared-template reference (cs_template) plus the per-link dynamic_informations (name / photo) and dynamic_links.","operationId":"getLinkDynamicOverrides","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link.","schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"}],"responses":{"200":{"description":"Overrides retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DynamicOverridesResponse"}}}},"400":{"description":"Invalid link id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}},"put":{"tags":["Landing Pages"],"summary":"Replace a link's dynamic overrides (Model B)","description":"Full replace of the { cs_template, dynamic_informations, dynamic_links } triplet - any field you omit is cleared. PUT {} resets the link to no overrides and detaches the template. This is the only API surface that can (re)attach or detach a shared template on an existing link. Every write re-mirrors the edge, so the change is live immediately.","operationId":"replaceLinkDynamicOverrides","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link.","schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DynamicOverridesRequest"},"examples":{"attach":{"summary":"Attach a template and set identity","value":{"cs_template":"6650b2aa11cc22dd33ee44ff","dynamic_informations":{"n":"Ana","pp":{"url":"https://ucarecdn.com/example-uuid/"}},"dynamic_links":[{"title":"My shop","url":"https://shop.example/ana"}]}},"reset":{"summary":"Reset (detach template, clear overrides)","value":{}}}}}},"responses":{"200":{"description":"Overrides replaced successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DynamicOverridesResponse"}}}},"400":{"description":"Invalid link id or override shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}},"patch":{"tags":["Landing Pages"],"summary":"Merge a link's dynamic overrides (Model B)","description":"Merge - only the keys you send change. dynamic_informations is merged one level deep (send just { dynamic_informations: { n: \"New name\" } } to rename without touching the photo); dynamic_links is replaced wholesale. Send cs_template: null to detach. An empty PATCH returns 400.","operationId":"patchLinkDynamicOverrides","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link.","schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DynamicOverridesRequest"},"examples":{"rename":{"summary":"Rename a creator, design untouched","value":{"dynamic_informations":{"n":"Ana Smith"}}},"detach":{"summary":"Detach the template","value":{"cs_template":null}}}}}},"responses":{"200":{"description":"Overrides merged successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DynamicOverridesResponse"}}}},"400":{"description":"Invalid link id, override shape, or empty patch.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}}},"/api/v1/templates/{template_id}/landing":{"get":{"tags":["Landing Pages"],"summary":"Read a template's landing page","description":"Return a template's Landing v2 page. Same shape as the link read, minus the link-only fields (active, dynamic, and a real preview_url).","operationId":"getTemplateLanding","parameters":[{"name":"template_id","in":"path","required":true,"description":"The unique identifier of the template.","schema":{"type":"string"},"example":"6650b2aa11cc22dd33ee44ff"},{"name":"state","in":"query","required":false,"schema":{"type":"string","enum":["published","draft"],"default":"published"}}],"responses":{"200":{"description":"Landing configuration retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingReadResponse"}}}},"400":{"description":"Invalid template id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Template not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}},"put":{"tags":["Landing Pages"],"summary":"Replace a template's landing page","description":"Replace the whole page. For a template, a write publishes and marks it as a v2 template (there is no active flag and no preview_url).","operationId":"replaceTemplateLanding","parameters":[{"name":"template_id","in":"path","required":true,"description":"The unique identifier of the template.","schema":{"type":"string"},"example":"6650b2aa11cc22dd33ee44ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteLandingRequest"}}}},"responses":{"200":{"description":"Page written and published successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteLandingResponse"}}}},"400":{"description":"Missing page or structurally invalid page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Template not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"413":{"description":"Serialized page over 2 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}},"patch":{"tags":["Landing Pages"],"summary":"Patch a template's landing page","description":"Targeted merge (same semantics as the link PATCH). Returns 409 if the template has no Landing v2 page yet.","operationId":"patchTemplateLanding","parameters":[{"name":"template_id","in":"path","required":true,"description":"The unique identifier of the template.","schema":{"type":"string"},"example":"6650b2aa11cc22dd33ee44ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteLandingRequest"}}}},"responses":{"200":{"description":"Page patched successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteLandingResponse"}}}},"400":{"description":"Missing page or structurally invalid page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Template not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"409":{"description":"No existing landing to patch (use PUT first).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}},"413":{"description":"Serialized page over 2 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}}},"/api/v1/templates/{template_id}/landing/history":{"get":{"tags":["Landing Pages"],"summary":"List a template's landing version history","description":"Snapshots newest first (rolling window of the last 60). ?include_pages=false for a metadata-only list.","operationId":"getTemplateLandingHistory","parameters":[{"name":"template_id","in":"path","required":true,"description":"The unique identifier of the template.","schema":{"type":"string"},"example":"6650b2aa11cc22dd33ee44ff"},{"name":"include_pages","in":"query","required":false,"schema":{"type":"boolean","default":true}}],"responses":{"200":{"description":"Version list retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingHistoryListResponse"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Template not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}}},"/api/v1/templates/{template_id}/landing/history/{version_id}":{"get":{"tags":["Landing Pages"],"summary":"Read one template landing version","description":"Return a single history snapshot with its full page.","operationId":"getTemplateLandingVersion","parameters":[{"name":"template_id","in":"path","required":true,"description":"The unique identifier of the template.","schema":{"type":"string"},"example":"6650b2aa11cc22dd33ee44ff"},{"name":"version_id","in":"path","required":true,"description":"The snapshot id.","schema":{"type":"string"},"example":"6650c3aa11cc22dd33ee44ff"}],"responses":{"200":{"description":"Version retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingHistoryVersionResponse"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Version not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}}},"/api/v1/landing-engine":{"get":{"tags":["Landing Pages"],"summary":"Get the render contract (\"the engine\")","description":"A machine-readable description of the Page JSON we render: the page/theme envelope plus the full live catalog of section types (every type's layouts, content slots with value shapes, and style keys). Generated from the same section registry the editor uses, so it never drifts from what actually renders. Cacheable for 5 minutes.","operationId":"getLandingEngineContract","parameters":[{"name":"section","in":"query","required":false,"description":"Filter the catalog to a single section type (e.g. hero). An unknown type returns 404.","schema":{"type":"string"},"example":"hero"}],"responses":{"200":{"description":"Contract retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EngineContractResponse"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown section type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LandingError"}}}}}}},"/api/v1/landing-engine/example":{"get":{"tags":["Landing Pages"],"summary":"Get an example page","description":"A ready-to-PUT example page (hero + links_list) to bootstrap integrators. Cacheable for 5 minutes.","operationId":"getLandingEngineExample","responses":{"200":{"description":"Example retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EngineExampleResponse"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/shield":{"get":{"tags":["Shield"],"summary":"Shield contract (condition vocabulary, actions, presets, bots)","description":"Everything you need to build a valid Shield configuration without hard-coding enums: every condition type and the values it accepts, the three actions, the block screens, the simple buckets, the presets and the registered-bot registry. Generated from the same catalogues the dashboard renders, so it always matches the live product. Static — cache it.","operationId":"getShieldContract","responses":{"200":{"description":"The Shield contract.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldContractResponse"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"405":{"description":"Method not allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MethodNotAllowedError"}}}}}}},"/api/v1/shield/presets":{"get":{"tags":["Shield"],"summary":"List the Shield presets","description":"The one-click protection profiles you can apply with `{\"preset\": \"<id>\"}` on any Shield write, plus the simple buckets each of them configures.","operationId":"listShieldPresets","responses":{"200":{"description":"The preset catalogue.","content":{"application/json":{"schema":{"type":"object","properties":{"presets":{"type":"array","items":{"$ref":"#/components/schemas/ShieldPreset"}},"buckets":{"$ref":"#/components/schemas/ShieldBucketCatalogue"},"version":{"type":"string","example":"2.0.0"}}},"examples":{"presets":{"summary":"The three built-in presets","value":{"version":"2.0.0","presets":[{"id":"instagram","name":"Instagram Optimized","description":"Bots and anonymized traffic see your landing — your real page stays hidden from link scanners.","block_screen":"landing","default_decoy_source":"link","buckets":{"bot_known":"block","bot_unknown":"block","net_proxy":"block","net_vpn":"block","net_datacenter":"block"},"cuts_deeplinks":true},{"id":"bots_only","name":"Bots → 3 dots","description":"Send every bot to the 3-dots \"open in browser\" overlay and leave real human proxy / VPN traffic untouched.","block_screen":"three_dots","default_decoy_source":"template","buckets":{"bot_known":"block","bot_unknown":"block"},"cuts_deeplinks":false},{"id":"hard_404","name":"Hard block (404)","description":"Block bots, proxies, VPNs and datacenter IPs with a plain 404 — no landing needed.","block_screen":"not_found","default_decoy_source":"template","buckets":{"bot_known":"block","bot_unknown":"block","net_proxy":"block","net_vpn":"block","net_datacenter":"block"},"cuts_deeplinks":false}]}}}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/shield/bots":{"get":{"tags":["Shield"],"summary":"List the registered crawlers","description":"The crawlers LinkScale recognizes by name. Use an id as a BOT condition value, or as a `bot:<id>` bucket, to give one specific crawler its own outcome. Anything automated that is not in this list is `unknown`.","operationId":"listShieldBots","responses":{"200":{"description":"The bot registry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldBotRegistry"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/links/{link_id}/shield":{"get":{"tags":["Shield"],"summary":"Read a link's Shield configuration","description":"Return the link's complete Shield config: the ordered rules (each with a plain-English summary), the same configuration expressed as simple buckets, the default block screen, the applied preset and a readiness verdict.\n\nLinks that have never been migrated to the rule engine return `model: \"legacy\"` together with the deterministic compilation of their old tri-state fields — that is exactly what the dashboard opens, and exactly what the serve layer falls back to.\n\nThe decoy page snapshot is omitted by default (it can be hundreds of KB); pass `?include_page=true` to get it inline.","operationId":"getLinkShield","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link.","schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"},{"name":"include_page","in":"query","required":false,"description":"Include the decoy page snapshot in the response.","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Shield configuration retrieved successfully.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldResponse"},"examples":{"instagram":{"summary":"A link protected with the Instagram preset","value":{"shield":{"enabled":true,"preset":"instagram","model":"v2","mode":"simple","rules":[{"id":"simple_bot_known","enabled":true,"label":"","match":{"op":"any","rules":[{"type":"BOT","match":"is","values":["known"]}]},"action":{"type":"block"},"summary":"registered bots"}],"buckets":{"bot_known":{"action":"block"},"bot_unknown":{"action":"block"},"net_proxy":{"action":"block"},"net_vpn":{"action":"block"},"net_datacenter":{"action":"block"}},"block":{"behavior":"landing","landing":{"source":"link","link_u":"john","link_domain":"lnkdm.me","link_name":"John's page","has_page_snapshot":false}},"ready":true,"issues":[],"stats":{"rules_total":5,"rules_enabled":5,"custom_rules":0,"blocks":5,"allows":0,"redirects":0}},"contract":{"url":"/api/v1/shield","version":"2.0.0"},"link":{"link_id":"6650a1bb22cc33dd44ee55ff","u":"john","domain":"lnkdm.me"},"project":{"project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","project_name":"My Project"}}}}}}},"400":{"description":"Invalid link id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldError"}}}}}},"put":{"tags":["Shield"],"summary":"Replace a link's Shield configuration","description":"Replace the whole config: anything you omit is reset. Use it to set a link's protection from scratch — `{\"enabled\": true, \"preset\": \"instagram\"}` is a complete, serveable setup in one call.\n\nDecoy references are resolved server-side: send `template_id` and the API attaches the template's name plus a page snapshot as the serve-time fallback; send `{\"source\": \"self\"}` and the link's own landing is used. A configuration whose decoy has no page to render is rejected with 400 rather than silently degrading to a 404.\n\nThe write goes through the same pipeline as a dashboard save, including the mandatory edge re-mirror, so it is live immediately.","operationId":"replaceLinkShield","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldWriteRequest"},"examples":{"preset":{"summary":"One-call protection with a preset","value":{"enabled":true,"preset":"instagram"}},"preset_with_template_decoy":{"summary":"Preset, but blocked traffic sees one of your templates","value":{"enabled":true,"preset":"instagram","block":{"behavior":"landing","landing":{"source":"template","template_id":"692d91eec003c7d6b3ab6273"}}}},"buckets":{"summary":"Simple buckets, no rule writing","value":{"enabled":true,"buckets":{"bot_known":"block","bot_unknown":"block","net_vpn":{"action":"redirect","redirect_url":"https://example.com/no-vpn"},"bot:googlebot":"allow"},"block":{"behavior":"three_dots"}}},"advanced":{"summary":"Hand-written rules","value":{"enabled":true,"rules":[{"id":"allow_google","label":"Let Google index","match":{"op":"any","rules":[{"type":"BOT","match":"is","values":["googlebot"]}]},"action":{"type":"allow"}},{"id":"fr_vpn_to_decoy","label":"French VPN traffic","match":{"op":"all","rules":[{"type":"COUNTRY","match":"is","values":["FR"]},{"type":"NETWORK","match":"is","values":["vpn","datacenter"]}]},"action":{"type":"block","block":{"behavior":"landing","landing":{"source":"template","template_id":"692d91eec003c7d6b3ab6273"}}}},{"id":"scanners","match":{"op":"any","rules":[{"type":"BOT","match":"is","values":["any"]}]},"action":{"type":"block"}}],"block":{"behavior":"not_found"}}}}}}},"responses":{"200":{"description":"Shield configuration saved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldResponse"}}}},"400":{"description":"Validation failed (unknown condition type or value, missing redirect_url, unknown preset or bucket), or the configuration leaves a decoy landing with nothing to render.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldError"}}}}}},"patch":{"tags":["Shield"],"summary":"Update part of a link's Shield configuration","description":"Same body as PUT, but only the keys you send are touched — everything else keeps its stored value, and `block` is merged one level deep instead of replaced. Use it to flip a single bucket, swap the decoy or turn Shield off without restating the whole config.","operationId":"updateLinkShield","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldWriteRequest"},"examples":{"one_bucket":{"summary":"Whitelist Googlebot, leave everything else alone","value":{"buckets":{"bot:googlebot":"allow"}}},"swap_decoy":{"summary":"Point blocked traffic at another template","value":{"block":{"behavior":"landing","landing":{"source":"template","template_id":"6831c0c1b3e51ec2a859bd85"}}}},"pause":{"summary":"Turn Shield off without losing the configuration","value":{"enabled":false}}}}}},"responses":{"200":{"description":"Shield configuration saved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldResponse"}}}},"400":{"description":"Empty body, validation failure, or an unconfigured decoy.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldError"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldError"}}}}}},"delete":{"tags":["Shield"],"summary":"Disable Shield and clear its configuration","description":"Turn Shield off and wipe everything it stored: rules, default block screen, applied preset and the legacy tri-state fields (so nothing can be resurrected by the legacy fallback). The link itself is untouched. To pause protection while keeping the setup, send `PATCH {\"enabled\": false}` instead.","operationId":"deleteLinkShield","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"string"},"example":"6650a1bb22cc33dd44ee55ff"}],"responses":{"200":{"description":"Shield disabled and configuration cleared.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldResponse"}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShieldError"}}}}}}},"/api/v1/assets":{"put":{"tags":["Assets"],"summary":"Generate upload signature","description":"Generate a secure signature for uploading files directly to Uploadcare CDN.\n\n## Complete Upload Workflow\n\n### Step 1: Request Upload Signature\nCall this endpoint to get a secure upload signature that expires after your specified time (default: 10 minutes).\n\n```javascript\nconst response = await fetch('https://dashboard.linkscale.to/api/v1/assets', {\n  method: 'PUT',\n  headers: {\n    'Content-Type': 'application/json',\n    'Authorization': 'Bearer YOUR_API_KEY'\n  },\n  body: JSON.stringify({\n    expiration_minutes: 10\n  })\n});\n\nconst { upload_config, project } = await response.json();\n```\n\n### Step 2: Upload File to Uploadcare\nUse the signature to upload your file directly to Uploadcare using multipart/form-data.\n\n**Required FormData Fields:**\n- `UPLOADCARE_PUB_KEY`: Public key from upload_config\n- `UPLOADCARE_STORE`: Set to 'auto' for automatic storage\n- `signature`: Secure signature from upload_config\n- `expire`: Unix timestamp from upload_config\n- `file`: Your file as Blob/File object\n- `metadata[project_id]`: Project ID from upload_config.metadata\n- `metadata[api_key_id]`: API key ID from upload_config.metadata\n\n**Supported File Types:**\n- **Images**: PNG, JPEG, GIF, WebP, SVG\n- **Videos**: MP4, WebM, MOV, AVI\n- **Audio**: MP3, WAV, OGG, M4A\n- **Documents**: PDF, ZIP, JSON, XML\n- **Text**: TXT, CSV, HTML, CSS\n\n**Complete Upload Example (Browser):**\n```javascript\nconst formData = new FormData();\nformData.append('UPLOADCARE_PUB_KEY', upload_config.public_key);\nformData.append('UPLOADCARE_STORE', 'auto');\nformData.append('signature', upload_config.signature);\nformData.append('expire', upload_config.expire.toString());\nformData.append('file', fileInput.files[0]); // Browser File object\nformData.append('metadata[project_id]', upload_config.metadata.project_id);\nformData.append('metadata[api_key_id]', upload_config.metadata.api_key_id);\n\nconst uploadResponse = await fetch(upload_config.upload_url, {\n  method: 'POST',\n  body: formData\n});\n\nconst { file: fileId } = await uploadResponse.json();\nconsole.log('File ID:', fileId); // e.g., \"17be4678-dab7-4bc7-8753-28914a22960a\"\n```\n\n**Complete Upload Example (Node.js):**\n```javascript\nconst fs = require('fs');\nconst path = require('path');\n\n// Read file and create Blob\nconst fileBuffer = fs.readFileSync('./image.jpg');\nconst fileName = path.basename('./image.jpg');\n\n// Detect MIME type from extension\nconst mimeTypes = {\n  '.png': 'image/png',\n  '.jpg': 'image/jpeg',\n  '.jpeg': 'image/jpeg',\n  '.gif': 'image/gif',\n  '.webp': 'image/webp',\n  '.svg': 'image/svg+xml',\n  '.mp4': 'video/mp4',\n  '.webm': 'video/webm',\n  '.pdf': 'application/pdf',\n  '.json': 'application/json',\n  '.txt': 'text/plain'\n};\n\nconst fileExtension = path.extname('./image.jpg').toLowerCase();\nconst mimeType = mimeTypes[fileExtension] || 'application/octet-stream';\nconst fileBlob = new Blob([fileBuffer], { type: mimeType });\n\n// Create FormData with all required fields\nconst formData = new FormData();\nformData.append('UPLOADCARE_PUB_KEY', upload_config.public_key);\nformData.append('UPLOADCARE_STORE', 'auto');\nformData.append('signature', upload_config.signature);\nformData.append('expire', upload_config.expire.toString());\nformData.append('file', fileBlob, fileName);\nformData.append('metadata[project_id]', upload_config.metadata.project_id);\nformData.append('metadata[api_key_id]', upload_config.metadata.api_key_id);\n\nconst uploadResponse = await fetch(upload_config.upload_url, {\n  method: 'POST',\n  body: formData\n});\n\nconst { file: fileId } = await uploadResponse.json();\n```\n\n### Step 3: Poll for Validation\nAfter uploading, poll GET /api/v1/assets/{file_id} until the file is validated (usually 2-3 seconds).\n\n```javascript\nconst pollValidation = async (fileId, maxAttempts = 10, delayMs = 2000) => {\n  for (let i = 0; i < maxAttempts; i++) {\n    const response = await fetch(`https://dashboard.linkscale.to/api/v1/assets/${fileId}`, {\n      headers: { 'Authorization': 'Bearer YOUR_API_KEY' }\n    });\n    \n    if (response.ok) {\n      const data = await response.json();\n      console.log('✅ File validated!');\n      return data; // Asset ready to use\n    }\n    \n    if (response.status === 404) {\n      console.log(`⏳ Still processing... (${i + 1}/${maxAttempts})`);\n      await new Promise(resolve => setTimeout(resolve, delayMs));\n      continue;\n    }\n    \n    throw new Error('Validation failed');\n  }\n  throw new Error('Timeout - file may still be processing');\n};\n\nconst asset = await pollValidation(fileId);\nconsole.log('CDN URL:', asset.asset.provider_file_url);\n```\n\n### Step 4: Use Your Asset\nOnce validated, use the `provider_file_url` from the asset object to access your file via CDN.\n\n```javascript\n// Use in your application\nconst cdnUrl = asset.asset.provider_file_url;\n// Example: \"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/\"\n```\n\n## Security Features\n\n- **Time-limited signatures**: Signatures expire after specified time (1-60 minutes)\n- **MIME type restrictions**: Optionally restrict allowed file types\n- **File size limits**: Optionally set maximum file size in bytes\n- **Metadata tracking**: Automatically tagged with project_id and api_key_id\n- **Direct upload**: Files never transit through your server\n- **Automatic validation**: Webhook validates files asynchronously\n- **Invalid files deleted**: Files failing validation are automatically removed\n\n## Common MIME Types\n\n**Images:**\n- `image/png` - PNG images\n- `image/jpeg` - JPEG images\n- `image/gif` - GIF images\n- `image/webp` - WebP images\n- `image/svg+xml` - SVG images\n\n**Videos:**\n- `video/mp4` - MP4 videos\n- `video/webm` - WebM videos\n\n**Documents:**\n- `application/pdf` - PDF documents\n- `application/json` - JSON files\n\n**Text:**\n- `text/plain` - Text files\n- `text/csv` - CSV files\n\n## Expected Timing\n\n- **Signature generation**: < 100ms\n- **Upload to Uploadcare**: Depends on file size and connection\n- **Validation (webhook)**: 1-3 seconds (normal)\n- **Recommended polling**: Every 2 seconds for max 30 seconds\n\n## Error Handling\n\n**Signature Request Errors:**\n- 400: Invalid expiration_minutes (must be 1-60)\n- 401: Missing or invalid API key\n\n**Upload Errors:**\n- Check Uploadcare response for upload failures\n- Common: signature expired, file too large, invalid MIME type\n\n**Validation Errors:**\n- 404: File not yet validated (keep polling)\n- Timeout after 30 seconds: File may still be processing or failed validation\n","operationId":"generateUploadSignature","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateSignatureRequest"},"examples":{"basic":{"summary":"Basic signature request (10 min expiration)","value":{"expiration_minutes":10}},"with_restrictions":{"summary":"With file type and size restrictions (15 min, 10MB max)","value":{"expiration_minutes":15,"allowed_mime_types":["image/png","image/jpeg","image/webp"],"max_file_size":10485760}},"images_only":{"summary":"Images only (5 min, 5MB limit)","value":{"expiration_minutes":5,"allowed_mime_types":["image/png","image/jpeg","image/gif"],"max_file_size":5242880}},"videos_only":{"summary":"Videos only (30 min, 50MB limit)","value":{"expiration_minutes":30,"allowed_mime_types":["video/mp4","video/webm"],"max_file_size":52428800}},"documents":{"summary":"Documents (PDF, JSON) - 10MB limit","value":{"expiration_minutes":15,"allowed_mime_types":["application/pdf","application/json"],"max_file_size":10485760}}}}}},"responses":{"200":{"description":"Signature generated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadSignatureResponse"},"examples":{"basic_response":{"summary":"Successful signature generation","value":{"upload_config":{"public_key":"demopublickey","expire":1728481200,"signature":"a8b7c6d5e4f3g2h1i0j9k8l7m6n5o4p3","upload_url":"https://upload.uploadcare.com/base/","metadata":{"project_id":"proj_abc123def456","api_key_id":"lk_xyz789abc123"}},"project":{"project_id":"proj_abc123def456","project_name":"My Project"}}},"detailed_response":{"summary":"Complete response with all fields","description":"Complete response showing all fields returned from signature generation.\nUse these values to construct your FormData for Uploadcare upload.\n","value":{"upload_config":{"public_key":"demopublickey","expire":1728481200,"signature":"a8b7c6d5e4f3g2h1i0j9k8l7m6n5o4p3","upload_url":"https://upload.uploadcare.com/base/","metadata":{"project_id":"proj_abc123def456","api_key_id":"lk_xyz789abc123"}},"project":{"project_id":"proj_abc123def456","project_name":"My Project"}}}}}}},"400":{"description":"Bad request - Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_expiration":{"summary":"Invalid expiration time","value":{"success":false,"error":"Validation error","status":400,"message":"expiration_minutes must be between 1 and 60","timestamp":"2025-10-10T10:30:00.000Z"}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"missing_api_key":{"summary":"Missing API key","value":{"success":false,"error":"Unauthorized","status":401,"message":"API key required. Please provide a valid API key in the Authorization header with format: Bearer lk_xxxxx","timestamp":"2025-10-10T10:30:00.000Z"}}}}}}}},"get":{"tags":["Assets"],"summary":"Search & list assets","description":"Browse the media library of your project — every file uploaded through the\nAPI **and** every file uploaded from the dashboard — with search, category\nfiltering, sorting and pagination.\n\n## Searching\n\n`search` matches the filename and the MIME type, partially and\ncase-insensitively (`logo` finds `linkscale-logo.png`, `png` finds every\nPNG). Combine it freely with `file_type` and `sort`:\n\n```\nGET /api/v1/assets?search=logo&file_type=image&sort=largest&limit=20\n```\n\n**File type categories** — matched on the MIME type, falling back to the\nfilename extension when the MIME is missing or generic\n(`application/octet-stream`), so nothing hides from the filter:\n\n- `image`: PNG, JPEG, GIF, WebP, SVG, AVIF, HEIC…\n- `video`: MP4, WebM, MOV, AVI, MKV…\n- `audio`: MP3, WAV, OGG, M4A, FLAC…\n- `application`: PDF, ZIP, JSON, XML, Office documents\n- `text`: TXT, CSV, HTML, CSS, Markdown\n\n## Displaying the media\n\nEvery asset comes back with everything a gallery needs — no CDN knowledge\nrequired:\n\n| Field | Use it for |\n|---|---|\n| `thumbnail_url` | Grid tile (320px, never upscaled). `null` for non-images. |\n| `preview_url` | Lightbox / detail view (1024px). `null` for non-images. |\n| `media_kind` | `image` / `video` / `audio` / `document` — pick the right tile. |\n| `is_image` | Shorthand: does this asset have previews at all? |\n| `provider_file_url` | The original file, full quality. |\n\n```html\n<img src=\"${asset.thumbnail_url}\" alt=\"${asset.provider_file_name}\" loading=\"lazy\">\n```\n\nBoth preview URLs are plain Uploadcare transformation links, so you can\nbuild your own instead — append operations to `provider_file_url`:\n\n```\n{provider_file_url}-/preview/600x600/-/format/auto/-/quality/smart/   # fit in a box\n{provider_file_url}-/scale_crop/300x300/center/                       # square crop\n{provider_file_url}-/rasterize/-/format/png/                          # rasterize an SVG\n```\n\nVideo, audio and PDF have **no** server-rendered still frame: `thumbnail_url`\nand `preview_url` are `null` there, and you should render your own placeholder\nfrom `media_kind`.\n\n## Paginating\n\n`total` counts every asset matching the filters (not just this page),\n`count` is the size of this page, and `has_more` tells you whether to keep\ngoing. Next page: `offset = offset + count`.\n\n**Use cases:** asset galleries, file managers, \"pick an image\" dialogs,\nauditing what a project has stored.\n","operationId":"listAssets","parameters":[{"name":"search","in":"query","required":false,"description":"Search in filename and MIME type (partial match, case-insensitive, max 100 chars)","schema":{"type":"string","maxLength":100},"example":"logo"},{"name":"file_type","in":"query","required":false,"description":"Filter by file category","schema":{"type":"string","enum":["image","video","audio","application","text"]},"example":"image"},{"name":"sort","in":"query","required":false,"description":"Result ordering:\n- `newest` (default) / `oldest` — by upload date\n- `largest` / `smallest` — by file size\n- `name` — alphabetical by filename\n","schema":{"type":"string","enum":["newest","oldest","largest","smallest","name"],"default":"newest"},"example":"newest"},{"name":"limit","in":"query","required":false,"description":"Number of results per page (1-100)","schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"example":50},{"name":"offset","in":"query","required":false,"description":"Number of items to skip for pagination","schema":{"type":"integer","minimum":0,"default":0},"example":0}],"responses":{"200":{"description":"Assets retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetsListResponse"},"examples":{"list_all":{"summary":"List all assets (default)","value":{"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_file_url":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/","provider_date_time_uploaded":"2025-10-09T10:30:00.000Z","file_size":123456,"media_kind":"image","is_image":true,"thumbnail_url":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/320x320/-/format/auto/-/quality/smart/","preview_url":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/1024x1024/-/format/auto/-/quality/smart/","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_file_url":"https://ucarecdn.com/28cf5789-ebc8-5cd8-9864-39a25b33a71b/","provider_date_time_uploaded":"2025-10-09T11:15:00.000Z","file_size":5242880,"media_kind":"video","is_image":false,"thumbnail_url":null,"preview_url":null,"video_info":{"duration":30000,"bitrate":1400000},"created_at":"2025-10-09T11:15:03.000Z"}],"total":42,"count":2,"limit":50,"offset":0,"has_more":false,"project":{"project_id":"proj_abc123","project_name":"My Project"}}},"search_images":{"summary":"Search \"logo\" among images, biggest first","description":"`GET /api/v1/assets?search=logo&file_type=image&sort=largest&limit=2`\n","value":{"assets":[{"_id":"67890xyz","file_id":"17be4678-dab7-4bc7-8753-28914a22960a","provider_file_name":"linkscale-logo.png","file_mime_type":"image/png","provider_file_url":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/","file_size":123456,"media_kind":"image","is_image":true,"thumbnail_url":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/320x320/-/format/auto/-/quality/smart/","preview_url":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/1024x1024/-/format/auto/-/quality/smart/","image_info":{"width":1920,"height":1080},"created_at":"2025-10-09T10:30:05.000Z"}],"total":8,"count":1,"limit":2,"offset":0,"has_more":true,"project":{"project_id":"proj_abc123","project_name":"My Project"}}}}}}},"400":{"description":"Bad request - Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_limit":{"summary":"Invalid limit parameter","value":{"success":false,"error":"Validation error","status":400,"message":"limit must be between 1 and 100","timestamp":"2025-10-10T10:30:00.000Z"}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/assets/{file_id}":{"get":{"tags":["Assets"],"summary":"Get asset details & validation polling","description":"Retrieve information about a specific asset by its Uploadcare file ID.\n\nThis endpoint serves two purposes: fetching any asset already in the\nlibrary (take the `file_id` from `GET /api/v1/assets`), and polling for a\nfreshly uploaded file to finish validating.\n\n## Displaying it\n\nThe response carries `thumbnail_url` (320px) and `preview_url` (1024px) —\nready-to-render CDN links you can put straight into an `<img>`. Both are\n`null` when `media_kind` is not `image`; for those, render your own\nplaceholder and link to `provider_file_url`.\n\n## Which id to pass\n\nFiles uploaded **through the API** are keyed on `file_id`; files uploaded\n**from the dashboard** carry the very same Uploadcare UUID under\n`provider_file_id`. This endpoint accepts either, and always echoes it back\nnormalised as `file_id` — so any id you read from `GET /api/v1/assets`\nresolves here.\n\n## Primary Use: Validation Polling\n\nAfter uploading a file to Uploadcare, you **MUST** poll this endpoint to verify the file has been validated by the webhook.\n\n### Why Polling is Required\n\nThe upload process is asynchronous:\n1. **You upload** → File goes to Uploadcare CDN\n2. **Uploadcare** → Sends webhook notification to LinkScale\n3. **LinkScale webhook** → Validates file, extracts metadata (dimensions, duration, etc.)\n4. **Database** → Asset saved with all metadata\n5. **You poll** → Get confirmation that asset is ready\n\n**Processing includes:**\n- File metadata extraction (size, MIME type, filename)\n- Image analysis (dimensions, format, color mode, DPI)\n- Video analysis (duration, bitrate, codecs)\n- Audio analysis (duration, bitrate, codec)\n- Validation checks\n- Automatic deletion of invalid files\n\n### Validation Flow\n\n```\nUpload to Uploadcare → Get file_id → Poll this endpoint → 200 OK → Use asset\n                                        ↓\n                                      404 = Still processing (wait 2s, retry)\n```\n\n### Expected Timing\n\n- **Normal validation**: 1-3 seconds\n- **Large files**: Up to 5-10 seconds\n- **Recommended polling**: Every 2 seconds\n- **Maximum attempts**: 10-30 attempts (20-60 seconds total)\n- **Give up after**: 30 seconds (file likely failed validation)\n\n### Complete Polling Implementation\n\n**Basic Polling (Recommended):**\n```javascript\nconst pollValidation = async (fileId, maxAttempts = 10, delayMs = 2000) => {\n  console.log('⏳ Polling for validation...');\n  \n  for (let i = 0; i < maxAttempts; i++) {\n    try {\n      const response = await fetch(\n        `https://dashboard.linkscale.to/api/v1/assets/${fileId}`,\n        {\n          method: 'GET',\n          headers: {\n            'Authorization': 'Bearer YOUR_API_KEY'\n          }\n        }\n      );\n    \n    if (response.ok) {\n        const data = await response.json();\n        console.log('✅ File validated and ready!');\n        console.log('CDN URL:', data.asset.provider_file_url);\n        return data;\n    }\n    \n    if (response.status === 404) {\n        console.log(`Attempt ${i + 1}/${maxAttempts}: Still processing...`);\n        await new Promise(resolve => setTimeout(resolve, delayMs));\n      continue;\n    }\n    \n      // Other error\n      const errorText = await response.text();\n      throw new Error(`Validation check failed: ${response.status} - ${errorText}`);\n      \n    } catch (error) {\n      console.warn(`Attempt ${i + 1}/${maxAttempts} error:`, error.message);\n      await new Promise(resolve => setTimeout(resolve, delayMs));\n    }\n  }\n  \n  throw new Error('Validation timeout - file may still be processing or failed');\n};\n\n// Usage\ntry {\n  const asset = await pollValidation(fileId);\n  // Asset is ready! Use the CDN URL\n  const cdnUrl = asset.asset.provider_file_url;\n  console.log('Use this URL:', cdnUrl);\n} catch (error) {\n  console.error('Upload failed:', error.message);\n}\n```\n\n**Advanced Polling with Exponential Backoff:**\n```javascript\nconst pollValidationWithBackoff = async (fileId) => {\n  const delays = [1000, 2000, 2000, 3000, 5000]; // Progressive delays\n  \n  for (let i = 0; i < delays.length; i++) {\n    const response = await fetch(\n      `https://dashboard.linkscale.to/api/v1/assets/${fileId}`,\n      { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }\n    );\n    \n    if (response.ok) {\n      return await response.json(); // ✅ Success!\n    }\n    \n    if (response.status === 404 && i < delays.length - 1) {\n      console.log(`⏳ Waiting ${delays[i]}ms...`);\n      await new Promise(r => setTimeout(r, delays[i]));\n      continue;\n    }\n    \n    if (response.status !== 404) {\n      throw new Error(`Validation failed: ${response.status}`);\n    }\n  }\n  \n  throw new Error('Timeout after multiple attempts');\n};\n```\n\n### Response Data\n\nOnce validated (200 OK), the response includes:\n- **File metadata**: ID, name, MIME type, size\n- **CDN URL**: `provider_file_url` for accessing the file\n- **Upload info**: Original upload timestamp\n- **Image metadata** (if image): width, height, format, color mode, DPI\n- **Video metadata** (if video): duration, bitrate, codecs\n- **Project info**: Which project owns this asset\n\n### Error Handling\n\n**404 Response**: File not yet validated\n- **Action**: Wait 2 seconds and retry\n- **Normal**: This is expected during polling\n- **Maximum retries**: 10-30 attempts recommended\n\n**401 Response**: Unauthorized\n- **Cause**: Invalid or missing API key\n- **Action**: Check your Authorization header\n\n**Other errors**: Validation failed\n- **Action**: File may be invalid or corrupted\n\n### Complete Upload + Polling Example\n\n```javascript\nconst uploadFile = async (file) => {\n  // Step 1: Get signature\n  const sigResponse = await fetch('https://dashboard.linkscale.to/api/v1/assets', {\n    method: 'PUT',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': 'Bearer YOUR_API_KEY'\n    },\n    body: JSON.stringify({ expiration_minutes: 10 })\n  });\n  \n  const { upload_config } = await sigResponse.json();\n  \n  // Step 2: Upload to Uploadcare\n  const formData = new FormData();\n  formData.append('UPLOADCARE_PUB_KEY', upload_config.public_key);\n  formData.append('UPLOADCARE_STORE', 'auto');\n  formData.append('signature', upload_config.signature);\n  formData.append('expire', upload_config.expire.toString());\n  formData.append('file', file);\n  formData.append('metadata[project_id]', upload_config.metadata.project_id);\n  formData.append('metadata[api_key_id]', upload_config.metadata.api_key_id);\n  \n  const uploadResponse = await fetch(upload_config.upload_url, {\n    method: 'POST',\n    body: formData\n  });\n  \n  const { file: fileId } = await uploadResponse.json();\n  console.log('📤 File uploaded, ID:', fileId);\n  \n  // Step 3: Poll for validation (THIS ENDPOINT)\n  const asset = await pollValidation(fileId);\n  console.log('✅ Upload complete!');\n  console.log('CDN URL:', asset.asset.provider_file_url);\n  \n  return asset;\n};\n```\n","operationId":"getAssetById","parameters":[{"name":"file_id","in":"path","required":true,"description":"Uploadcare UUID of the file — either the id returned after uploading,\nor the `file_id` of any asset listed by `GET /api/v1/assets`.\n","schema":{"type":"string"},"example":"17be4678-dab7-4bc7-8753-28914a22960a"}],"responses":{"200":{"description":"Asset validated and retrieved successfully - File is ready to use!","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetDetailsResponse"},"examples":{"image_asset":{"summary":"Validated image asset","value":{"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_file_url":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/","provider_date_time_uploaded":"2025-10-09T10:30:00.000Z","file_size":123456,"media_kind":"image","is_image":true,"thumbnail_url":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/320x320/-/format/auto/-/quality/smart/","preview_url":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/1024x1024/-/format/auto/-/quality/smart/","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"}}},"video_asset":{"summary":"Validated video asset","value":{"asset":{"_id":"67891abc","project_id":"proj_abc123","file_id":"28cf5789-ebc8-5cd8-9864-39a25b33a71b","provider_file_name":"promo.mp4","file_mime_type":"video/mp4","provider_file_url":"https://ucarecdn.com/28cf5789-ebc8-5cd8-9864-39a25b33a71b/","provider_date_time_uploaded":"2025-10-09T11:15:00.000Z","file_size":5242880,"media_kind":"video","is_image":false,"thumbnail_url":null,"preview_url":null,"video_info":{"duration":30000,"bitrate":1400000,"video_codec":"h264","audio_codec":"aac"},"created_at":"2025-10-09T11:15:03.000Z"},"project":{"project_id":"proj_abc123","project_name":"My Project"}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"unauthorized":{"summary":"Missing or invalid API key","value":{"success":false,"error":"Unauthorized","status":401,"message":"API key required. Please provide a valid API key in the Authorization header with format: Bearer lk_xxxxx","timestamp":"2025-10-10T10:30:00.000Z"}}}}}},"404":{"description":"Asset not found or not yet validated - This is NORMAL during polling. Wait and retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Asset not found"},"message":{"type":"string","example":"Asset has not been validated yet or does not exist. Please wait a few seconds and try again."}}},"examples":{"not_yet_validated":{"summary":"File not yet validated (normal during polling)","value":{"error":"Asset not found","message":"Asset has not been validated yet or does not exist. Please wait a few seconds and try again."}}}}}}}}},"/api/v1/logs":{"get":{"tags":["Logs"],"summary":"Get project logs","description":"Retrieve raw visit or click logs for the project associated with the API key. All IP addresses are anonymized for privacy.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `logs.read_project`\n","operationId":"getProjectLogs","parameters":[{"name":"source","in":"query","required":false,"description":"Type of logs to retrieve: `visits` or `clicks`","schema":{"type":"string","enum":["visits","clicks"],"default":"visits"},"example":"visits"},{"name":"from","in":"query","required":false,"description":"Start date (ISO 8601 format, e.g. `2026-01-01T00:00:00.000Z`)","schema":{"type":"string","format":"date-time"},"example":"2026-03-01T00:00:00.000Z"},{"name":"to","in":"query","required":false,"description":"End date (ISO 8601 format)","schema":{"type":"string","format":"date-time"},"example":"2026-03-31T23:59:59.999Z"},{"name":"limit","in":"query","required":false,"description":"Number of results per page (1–100)","schema":{"type":"integer","minimum":1,"maximum":100,"default":30},"example":30},{"name":"last_timestamp","in":"query","required":false,"description":"Cursor for pagination. Pass the `timestamp` value of the last item from the previous page to fetch the next page.\n","schema":{"type":"string","format":"date-time"},"example":"2026-03-30T14:22:01.000Z"},{"name":"country","in":"query","required":false,"description":"Filter by 2-letter country code (e.g. `FR`, `US`)","schema":{"type":"string"},"example":"FR"},{"name":"visitor_type","in":"query","required":false,"description":"Filter by visitor type: `all`, `humans`, or `bots`","schema":{"type":"string","enum":["all","humans","bots"],"default":"all"},"example":"humans"}],"responses":{"200":{"description":"Logs retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogsResponse"}}}},"400":{"description":"Invalid parameters (invalid dates, etc.)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key does not have the required `logs.read_project` permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/folders/{folder_id}/logs":{"get":{"tags":["Logs"],"summary":"Get folder logs","description":"Retrieve raw visit or click logs for all links inside a specific folder. All IP addresses are anonymized for privacy.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `logs.read_folder`\n","operationId":"getFolderLogs","parameters":[{"name":"folder_id","in":"path","required":true,"description":"The unique identifier of the folder","schema":{"type":"string"},"example":"665a1f2e3b4c5d6e7f8a9b0c"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/0"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/1"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/2"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/3"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/4"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/5"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/6"}],"responses":{"200":{"description":"Logs retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogsResponse"}}}},"400":{"description":"Invalid parameters (missing folder_id, invalid dates, etc.)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key does not have the required `logs.read_folder` permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Folder not found in this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/links/{link_id}/logs":{"get":{"tags":["Logs"],"summary":"Get link logs","description":"Retrieve raw visit or click logs for a specific link. All IP addresses are anonymized for privacy.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `logs.read_link`\n","operationId":"getLinkLogs","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link","schema":{"type":"string"},"example":"665a1f2e3b4c5d6e7f8a9b0c"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/0"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/1"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/2"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/3"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/4"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/5"},{"$ref":"#/paths/~1api~1v1~1logs/get/parameters/6"}],"responses":{"200":{"description":"Logs retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogsResponse"}}}},"400":{"description":"Invalid parameters (missing link_id, invalid dates, etc.)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key does not have the required `logs.read_link` permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Link not found in this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/folders":{"get":{"tags":["Folders"],"summary":"Get all folders","description":"Retrieve a simple list of all folders in your project without statistics or analytics.\n\nThis is a lightweight endpoint designed for quick folder listing. For detailed analytics and statistics, use `/api/v1/folders/stats` instead.\n\n**Key Features:**\n- Fast performance (no analytics computation)\n- Returns basic folder information with links count\n- Sorted by creation date (newest first)\n- Requires `folders.read` permission\n\n**Use this endpoint when you need to:**\n- Display a folder selector/dropdown\n- List available folders without analytics\n- Get folder metadata quickly\n\n**Use `/api/v1/folders/stats` when you need:**\n- Traffic analytics and statistics\n- Date-range based metrics\n- Detailed performance data\n\n**Putting a link in a folder** is done from the link side — send\n`folder_id` on `PUT /api/v1/links` or `PATCH /api/v1/links/{link_id}`.\nSee the **Folders** section of the introduction.\n","operationId":"getFolders","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Folders retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FoldersListResponse"},"examples":{"successful_retrieval":{"summary":"Successful folders retrieval","value":{"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"}]}},"empty_folders":{"summary":"Project with no folders","value":{"project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","project":{"project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","project_name":"My Project"},"folders":[]}}}}}},"401":{"description":"Unauthorized - Invalid or missing API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_api_key":{"summary":"Invalid API key","value":{"success":false,"error":{"code":"INVALID_API_KEY","message":"Invalid or missing API key"}}},"missing_authorization":{"summary":"Missing authorization header","value":{"success":false,"error":{"code":"UNAUTHORIZED","message":"Authorization header is required"}}}}}}},"403":{"description":"Forbidden - Missing required permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"insufficient_permissions":{"summary":"Missing folders.read permission","value":{"success":false,"error":{"code":"INSUFFICIENT_PERMISSIONS","message":"API key does not have 'folders.read' permission","required_permission":"folders.read"}}}}}}},"405":{"description":"Method not allowed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MethodNotAllowedError"},"examples":{"put_not_allowed":{"summary":"PUT method not allowed","value":{"success":false,"error":"Method not allowed","status":405,"message":"HTTP method 'PUT' is not supported for this endpoint","allowedMethods":["GET","POST"],"attemptedMethod":"PUT","timestamp":"2025-10-19T10:30:00.000Z","hint":"Use GET to list folders, POST to create one"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"server_error":{"summary":"Server error","value":{"success":false,"error":{"code":"INTERNAL_SERVER_ERROR","message":"An unexpected error occurred while retrieving folders"}}}}}}}}},"post":{"tags":["Folders"],"summary":"Create a folder","description":"Create a folder in your project.\n\nA folder is just a name — it holds no links of its own. Once you have its\n`_id`, file links into it with `folder_id` on `PUT /api/v1/links`\n(creation) or `PATCH /api/v1/links/{link_id}` (an existing link):\n\n```json\n// 1. create the folder\nPOST /api/v1/folders          { \"name\": \"Q3 Campaign\" }\n// -> { \"folder\": { \"_id\": \"6a7491caaa4bce8130ea5a62\", ... } }\n\n// 2. create links straight into it\nPUT /api/v1/links             { \"type\": \"d_l\", \"u\": \"promo\", \"domain\": \"yourdomain.com\",\n                                \"url\": \"https://example.com\", \"folder_id\": \"6a7491caaa4bce8130ea5a62\" }\n```\n\nFolder names are **not** unique — posting the same name twice gives you\ntwo distinct folders. For an idempotent integration, call\n`GET /api/v1/folders` first and reuse the matching `_id`.\n\nRequires the `folders.create` permission.\n","operationId":"createFolder","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Body of `POST /api/v1/folders`.\n\nNames are not required to be unique — creating a folder whose name already\nexists gives you a second, distinct folder. If your integration must be\nidempotent, list the folders first and reuse the matching `_id`.\n","properties":{"name":{"type":"string","minLength":1,"maxLength":300,"description":"Name of the folder. Leading and trailing whitespace is trimmed.","example":"Q3 Campaign"}},"required":["name"]},"examples":{"create":{"summary":"Create a folder","value":{"name":"Q3 Campaign"}}}}}},"responses":{"201":{"description":"Folder created","content":{"application/json":{"schema":{"type":"object","description":"Response of the single-folder operations — `POST /api/v1/folders`,\n`GET`/`PATCH /api/v1/folders/{folder_id}`. The folder is returned under\n`folder`, alongside the owning project, matching the envelope of\n`GET /api/v1/folders`.\n","properties":{"project_id":{"type":"string","description":"Unique identifier of the project.","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"project":{"$ref":"#/components/schemas/ProjectRef"},"folder":{"type":"object","description":"A folder. Folders group links inside a project — they are the sections of the\ndashboard sidebar, and the unit the folder statistics and folder logs\nendpoints aggregate over.\n\nA folder holds no links of its own: membership is stored **on the link**, in\nits `folders` array. Put a link in a folder with `folder_id` (or `folders`) on\n`PUT /api/v1/links` and `PATCH /api/v1/links/{link_id}`.\n","properties":{"_id":{"type":"string","description":"Unique identifier of the folder. This is the value you send as `folder_id`.","example":"6a7491caaa4bce8130ea5a62"},"name":{"type":"string","description":"Name of the folder.","example":"Marketing Campaign"},"project_id":{"type":"string","description":"Project the folder belongs to.","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"links_count":{"type":"integer","description":"How many links currently sit in this folder.","example":15},"created_at":{"type":"string","format":"date-time","description":"When the folder was created.","example":"2025-10-19T10:30:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"When the folder was last renamed. Null on folders that have never been renamed.","example":"2025-10-19T15:45:00.000Z"}}}},"required":["project_id","project","folder"]},"examples":{"created":{"summary":"Newly created folder","value":{"project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","project":{"project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","project_name":"My Project"},"folder":{"_id":"6a7491caaa4bce8130ea5a62","name":"Q3 Campaign","project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","links_count":0,"created_at":"2026-08-06T13:53:14.405Z","updated_at":"2026-08-06T13:53:14.405Z"}}}}}}},"400":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"},"examples":{"missing_name":{"summary":"No name sent","value":{"error":"Validation failed","details":[{"field":"name","message":"name is required"}]}}}}}},"401":{"description":"Unauthorized - Invalid or missing API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden - Missing required permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"insufficient_permissions":{"summary":"Missing folders.create permission","value":{"error":"Permission denied","message":"This API key does not have permission to create folders. Please check your API key permissions."}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/folders/{folder_id}":{"parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"string"},"description":"Id of the folder, as returned by `GET /api/v1/folders`.","example":"6a7491caaa4bce8130ea5a62"}],"get":{"tags":["Folders"],"summary":"Get a folder","description":"Read one folder: its name, its timestamps and how many links currently sit\nin it. A folder that belongs to another project reads exactly like one\nthat does not exist (`404`).\n\nTo list the links **inside** the folder, call\n`GET /api/v1/links?folder_id={folder_id}`.\n\nRequires the `folders.read` permission.\n","operationId":"getFolder","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Folder retrieved","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v1~1folders/post/responses/201/content/application~1json/schema"},"examples":{"folder":{"summary":"A folder holding 15 links","value":{"project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","project":{"project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","project_name":"My Project"},"folder":{"_id":"6a7491caaa4bce8130ea5a62","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"}}}}}}},"401":{"description":"Unauthorized - Invalid or missing API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden - Missing `folders.read` permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Folder not found in this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"not_found":{"summary":"Unknown folder","value":{"error":"Folder not found","message":"Folder 6a7491caaa4bce8130ea5a62 does not exist in this project"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Folders"],"summary":"Rename a folder","description":"Rename a folder. Renaming is the only mutable property — the links inside\nare untouched, and their `folders` arrays keep the same ids.\n\nTo move a link between folders, `PATCH` the **link** instead.\n\nRequires the `folders.edit` permission.\n","operationId":"updateFolder","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Body of `PATCH /api/v1/folders/{folder_id}`. Renaming is the only mutable\nproperty of a folder — a link's membership is changed from the **link** side,\nwith `folder_id` / `folders` on `PATCH /api/v1/links/{link_id}`.\n","properties":{"name":{"type":"string","minLength":1,"maxLength":300,"description":"New name for the folder. Leading and trailing whitespace is trimmed.","example":"Q3 Campaign (archived)"}},"required":["name"]},"examples":{"rename":{"summary":"Rename","value":{"name":"Q3 Campaign (archived)"}}}}}},"responses":{"200":{"description":"Folder updated","content":{"application/json":{"schema":{"$ref":"#/paths/~1api~1v1~1folders/post/responses/201/content/application~1json/schema"}}}},"400":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"401":{"description":"Unauthorized - Invalid or missing API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden - Missing `folders.edit` permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Folder not found in this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Folders"],"summary":"Delete a folder","description":"Delete a folder.\n\n**Its links are not deleted.** Every link that was inside is detached and\nbecomes folder-less — the deleted id is removed from each link's `folders`\narray, so no link is left pointing at a folder that no longer exists. The\nresponse reports how many links were detached in `links_updated`.\n\nIrreversible. Requires the `folders.delete` permission.\n","operationId":"deleteFolder","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Folder deleted","content":{"application/json":{"schema":{"type":"object","description":"Response of `DELETE /api/v1/folders/{folder_id}`.\n\nDeleting a folder never deletes its links. Every link that was inside it is\ndetached and becomes folder-less; `links_updated` tells you how many.\n","properties":{"project_id":{"type":"string","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"project":{"$ref":"#/components/schemas/ProjectRef"},"folder_id":{"type":"string","description":"The folder that was deleted.","example":"6a7491caaa4bce8130ea5a62"},"deleted":{"type":"boolean","example":true},"links_updated":{"type":"integer","description":"How many links were taken out of the folder. The links themselves are untouched.","example":15}},"required":["project_id","project","folder_id","deleted","links_updated"]},"examples":{"deleted":{"summary":"Folder deleted, 15 links detached","value":{"project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","project":{"project_id":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480","project_name":"My Project"},"folder_id":"6a7491caaa4bce8130ea5a62","deleted":true,"links_updated":15}}}}}},"401":{"description":"Unauthorized - Invalid or missing API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden - Missing `folders.delete` permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Folder not found in this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/stats":{"get":{"tags":["Statistics"],"summary":"Get project statistics","description":"Get comprehensive statistics for your entire project. Supports optional timezone and lazy-loading of traffic data via traffic_data_type.\n\nWhen `include_clicks=true`, button clicks data is merged into each `trafficByUrls` item as a `button_clicks` array.\n","operationId":"getProjectStats","parameters":[{"name":"from","in":"query","required":false,"description":"Start date (ISO 8601)","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"description":"End date (ISO 8601)","schema":{"type":"string","format":"date-time"}},{"name":"timezone","in":"query","required":false,"description":"Timezone used to interpret the date window and to bucket time series (IANA tz name, e.g. `Europe/Paris`).\nDefaults to the project timezone, then UTC.\n\nHow `from` / `to` are interpreted:\n\n- **Without an offset** (`2026-07-01`, `2026-07-01T00:00:00`) the value is WALL-CLOCK time in `timezone`.\n  `?from=2026-07-01&to=2026-08-01&timezone=Europe/Paris` selects\n  `2026-06-30T22:00:00.000Z` to `2026-07-31T22:00:00.000Z` -- the same window the dashboard uses for July.\n  This is how you align API results with what you see in the dashboard.\n- **With an offset** (`2026-07-01T00:00:00Z`, `2026-07-01T00:00:00+02:00`) the value is an ABSOLUTE instant\n  and is used as-is. `timezone` then only affects how time series are bucketed by day/hour, never the totals.\n\nA bare date resolves to the START of that day, so `to=2026-08-01` means \"up to, and excluding, August 1st 00:00 local\".\n\nThe window actually queried is echoed back as `date_range.from_utc` / `date_range.to_utc`.\nAn unknown timezone name returns a 400.\n","schema":{"type":"string","example":"Europe/Paris"}},{"name":"traffic_data_type","in":"query","required":false,"description":"Type of traffic data to include","schema":{"type":"string","enum":["urls","links","both","none"],"default":"urls"}},{"name":"include_clicks","in":"query","required":false,"description":"When true, includes detailed button clicks data merged into each `trafficByUrls` item as `button_clicks` array.","schema":{"type":"boolean","default":false}},{"name":"exclude_referer","in":"query","required":false,"description":"Array of referers to exclude from statistics (JSON-encoded array in query string).\nEach referer string must not exceed 500 characters.\n\n**Example**: `?exclude_referer=[\"https://t.co/\",\"https://twitter.com\"]`\n","schema":{"type":"string"},"example":"[\"https://t.co/\",\"https://twitter.com\"]"},{"name":"exclude_useragent","in":"query","required":false,"description":"Array of user agents to exclude from statistics (JSON-encoded array in query string).\nEach user agent string must not exceed 500 characters.\n\n**Example**: `?exclude_useragent=[\"Googlebot\",\"TelegramBot\"]`\n","schema":{"type":"string"},"example":"[\"Googlebot\",\"TelegramBot\"]"},{"name":"exclude_country","in":"query","required":false,"description":"Array of country codes to exclude from statistics (JSON-encoded array in query string).\nEach country code must not exceed 10 characters. Use ISO 3166-1 alpha-2 codes (e.g., \"MX\", \"FR\", \"US\").\n\nAll clicks from the specified countries will be excluded from the returned statistics.\nThis filter applies to all metrics including summary counts, traffic by countries, top referrers, etc.\n\n**Example**: `?exclude_country=[\"MX\",\"FR\"]`\n","schema":{"type":"string"},"example":"[\"MX\",\"FR\",\"US\"]"},{"name":"traffic_type","in":"query","required":false,"description":"Type of traffic counting method to use for statistics.\n\n- `unique_users` (default): Counts unique users based on IP addresses. Each IP is counted only once per period.\n- `visits`: Counts all visits individually. Each request is counted separately.\n\nThis parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs.\n\n**Example**: `?traffic_type=visits`\n","schema":{"type":"string","enum":["visits","unique_users"],"default":"unique_users"},"example":"unique_users"}],"responses":{"200":{"description":"Project statistics retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectStatsResponse"}}}},"400":{"description":"Bad request - Invalid parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden - Missing required permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too many requests","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/folders/stats":{"get":{"tags":["Statistics"],"summary":"Get statistics for all folders","description":"Get statistics for all folders in your project.","operationId":"getFoldersStats","parameters":[{"name":"from","in":"query","required":false,"description":"Start date (ISO 8601)","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"description":"End date (ISO 8601)","schema":{"type":"string","format":"date-time"}},{"name":"timezone","in":"query","required":false,"description":"Timezone used to interpret the date window and to bucket time series (IANA tz name, e.g. `Europe/Paris`).\nDefaults to the project timezone, then UTC.\n\nHow `from` / `to` are interpreted:\n\n- **Without an offset** (`2026-07-01`, `2026-07-01T00:00:00`) the value is WALL-CLOCK time in `timezone`.\n  `?from=2026-07-01&to=2026-08-01&timezone=Europe/Paris` selects\n  `2026-06-30T22:00:00.000Z` to `2026-07-31T22:00:00.000Z` -- the same window the dashboard uses for July.\n  This is how you align API results with what you see in the dashboard.\n- **With an offset** (`2026-07-01T00:00:00Z`, `2026-07-01T00:00:00+02:00`) the value is an ABSOLUTE instant\n  and is used as-is. `timezone` then only affects how time series are bucketed by day/hour, never the totals.\n\nA bare date resolves to the START of that day, so `to=2026-08-01` means \"up to, and excluding, August 1st 00:00 local\".\n\nThe window actually queried is echoed back as `date_range.from_utc` / `date_range.to_utc`.\nAn unknown timezone name returns a 400.\n","schema":{"type":"string","example":"UTC"}},{"name":"exclude_referer","in":"query","required":false,"description":"Array of referers to exclude from statistics (JSON-encoded array in query string).\nEach referer string must not exceed 500 characters.\n\n**Example**: `?exclude_referer=[\"https://t.co/\",\"https://twitter.com\"]`\n","schema":{"type":"string"},"example":"[\"https://t.co/\",\"https://twitter.com\"]"},{"name":"exclude_useragent","in":"query","required":false,"description":"Array of user agents to exclude from statistics (JSON-encoded array in query string).\nEach user agent string must not exceed 500 characters.\n\n**Example**: `?exclude_useragent=[\"Googlebot\",\"TelegramBot\"]`\n","schema":{"type":"string"},"example":"[\"Googlebot\",\"TelegramBot\"]"},{"name":"exclude_country","in":"query","required":false,"description":"Array of country codes to exclude from statistics (JSON-encoded array in query string).\nEach country code must not exceed 10 characters. Use ISO 3166-1 alpha-2 codes (e.g., \"MX\", \"FR\", \"US\").\n\nAll clicks from the specified countries will be excluded from the returned statistics.\nThis filter applies to all metrics including summary counts, traffic by countries, top referrers, etc.\n\n**Example**: `?exclude_country=[\"MX\",\"FR\"]`\n","schema":{"type":"string"},"example":"[\"MX\",\"FR\",\"US\"]"},{"name":"traffic_type","in":"query","required":false,"description":"Type of traffic counting method to use for statistics.\n\n- `unique_users` (default): Counts unique users based on IP addresses. Each IP is counted only once per period.\n- `visits`: Counts all visits individually. Each request is counted separately.\n\nThis parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs.\n\n**Example**: `?traffic_type=visits`\n","schema":{"type":"string","enum":["visits","unique_users"],"default":"unique_users"},"example":"unique_users"}],"responses":{"200":{"description":"Folders statistics retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FoldersStatsResponse"}}}},"400":{"description":"Bad request - Invalid parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden - Missing required permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too many requests","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/folders/{folder_id}/stats":{"get":{"tags":["Statistics"],"summary":"Get statistics for a specific folder","description":"Get detailed statistics for a specific folder. Supports optional timezone and lazy-loading of traffic data via traffic_data_type.\n\nWhen `include_clicks=true`, button clicks data is merged into each `trafficByUrls` item as a `button_clicks` array.\n","operationId":"getFolderStats","parameters":[{"name":"folder_id","in":"path","required":true,"description":"The ID of the folder","schema":{"type":"string"},"example":"folder1"},{"name":"from","in":"query","required":false,"description":"Start date (ISO 8601)","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"description":"End date (ISO 8601)","schema":{"type":"string","format":"date-time"}},{"name":"timezone","in":"query","required":false,"description":"Timezone used to interpret the date window and to bucket time series (IANA tz name, e.g. `Europe/Paris`).\nDefaults to the project timezone, then UTC.\n\nHow `from` / `to` are interpreted:\n\n- **Without an offset** (`2026-07-01`, `2026-07-01T00:00:00`) the value is WALL-CLOCK time in `timezone`.\n  `?from=2026-07-01&to=2026-08-01&timezone=Europe/Paris` selects\n  `2026-06-30T22:00:00.000Z` to `2026-07-31T22:00:00.000Z` -- the same window the dashboard uses for July.\n  This is how you align API results with what you see in the dashboard.\n- **With an offset** (`2026-07-01T00:00:00Z`, `2026-07-01T00:00:00+02:00`) the value is an ABSOLUTE instant\n  and is used as-is. `timezone` then only affects how time series are bucketed by day/hour, never the totals.\n\nA bare date resolves to the START of that day, so `to=2026-08-01` means \"up to, and excluding, August 1st 00:00 local\".\n\nThe window actually queried is echoed back as `date_range.from_utc` / `date_range.to_utc`.\nAn unknown timezone name returns a 400.\n","schema":{"type":"string","example":"UTC"}},{"name":"traffic_data_type","in":"query","required":false,"description":"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.","schema":{"type":"string","enum":["urls","links","both","none"],"default":"urls"}},{"name":"include_clicks","in":"query","required":false,"description":"When true, includes detailed button clicks data merged into each `trafficByUrls` item as `button_clicks` array.","schema":{"type":"boolean","default":false}},{"name":"exclude_referer","in":"query","required":false,"description":"Array of referers to exclude from statistics (JSON-encoded array in query string).\nEach referer string must not exceed 500 characters.\n\n**Example**: `?exclude_referer=[\"https://t.co/\",\"https://twitter.com\"]`\n","schema":{"type":"string"},"example":"[\"https://t.co/\",\"https://twitter.com\"]"},{"name":"exclude_useragent","in":"query","required":false,"description":"Array of user agents to exclude from statistics (JSON-encoded array in query string).\nEach user agent string must not exceed 500 characters.\n\n**Example**: `?exclude_useragent=[\"Googlebot\",\"TelegramBot\"]`\n","schema":{"type":"string"},"example":"[\"Googlebot\",\"TelegramBot\"]"},{"name":"exclude_country","in":"query","required":false,"description":"Array of country codes to exclude from statistics (JSON-encoded array in query string).\nEach country code must not exceed 10 characters. Use ISO 3166-1 alpha-2 codes (e.g., \"MX\", \"FR\", \"US\").\n\nAll clicks from the specified countries will be excluded from the returned statistics.\nThis filter applies to all metrics including summary counts, traffic by countries, top referrers, etc.\n\n**Example**: `?exclude_country=[\"MX\",\"FR\"]`\n","schema":{"type":"string"},"example":"[\"MX\",\"FR\",\"US\"]"},{"name":"traffic_type","in":"query","required":false,"description":"Type of traffic counting method to use for statistics.\n\n- `unique_users` (default): Counts unique users based on IP addresses. Each IP is counted only once per period.\n- `visits`: Counts all visits individually. Each request is counted separately.\n\nThis parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs.\n\n**Example**: `?traffic_type=visits`\n","schema":{"type":"string","enum":["visits","unique_users"],"default":"unique_users"},"example":"unique_users"}],"responses":{"200":{"description":"Folder statistics retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderStatsResponse"}}}},"400":{"description":"Bad request - Invalid parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden - Missing required permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Folder not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too many requests","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/links/{link_id}/stats":{"get":{"tags":["Statistics"],"summary":"Get link statistics","description":"Retrieve analytics data for a specific link within a date range. Includes click aggregates from ClickHouse.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `statistics.read_link`\n\n**Backward compatible**: Existing fields unchanged; new field `analytics.button_clicks` added when `include_clicks=true`.\n","operationId":"getLinkStats","parameters":[{"name":"link_id","in":"path","required":true,"description":"The unique identifier of the link","schema":{"type":"string"},"example":"68b5c1a88568a81cc8355a64"},{"name":"from","in":"query","required":true,"description":"Start date for the analytics period (ISO 8601 format)","schema":{"type":"string","format":"date-time"},"example":"2025-10-26T00:00:00Z"},{"name":"to","in":"query","required":true,"description":"End date for the analytics period (ISO 8601 format)","schema":{"type":"string","format":"date-time"},"example":"2025-10-27T00:00:00Z"},{"name":"timezone","in":"query","required":false,"description":"Timezone used to interpret the date window and to bucket time series (IANA tz name, e.g. `Europe/Paris`).\nDefaults to the project timezone, then UTC.\n\nHow `from` / `to` are interpreted:\n\n- **Without an offset** (`2026-07-01`, `2026-07-01T00:00:00`) the value is WALL-CLOCK time in `timezone`.\n  `?from=2026-07-01&to=2026-08-01&timezone=Europe/Paris` selects\n  `2026-06-30T22:00:00.000Z` to `2026-07-31T22:00:00.000Z` -- the same window the dashboard uses for July.\n  This is how you align API results with what you see in the dashboard.\n- **With an offset** (`2026-07-01T00:00:00Z`, `2026-07-01T00:00:00+02:00`) the value is an ABSOLUTE instant\n  and is used as-is. `timezone` then only affects how time series are bucketed by day/hour, never the totals.\n\nA bare date resolves to the START of that day, so `to=2026-08-01` means \"up to, and excluding, August 1st 00:00 local\".\n\nThe window actually queried is echoed back as `date_range.from_utc` / `date_range.to_utc`.\nAn unknown timezone name returns a 400.\n","schema":{"type":"string","example":"UTC"}},{"name":"include_clicks","in":"query","required":false,"description":"When true, includes detailed button clicks data in the `analytics.button_clicks` array.","schema":{"type":"boolean","default":false}},{"name":"exclude_referer","in":"query","required":false,"description":"Array of referers to exclude from statistics (JSON-encoded array in query string).\nEach referer string must not exceed 500 characters.\n\n**Example**: `?exclude_referer=[\"https://t.co/\",\"https://twitter.com\"]`\n","schema":{"type":"string"},"example":"[\"https://t.co/\",\"https://twitter.com\"]"},{"name":"exclude_useragent","in":"query","required":false,"description":"Array of user agents to exclude from statistics (JSON-encoded array in query string).\nEach user agent string must not exceed 500 characters.\n\n**Example**: `?exclude_useragent=[\"Googlebot\",\"TelegramBot\"]`\n","schema":{"type":"string"},"example":"[\"Googlebot\",\"TelegramBot\"]"},{"name":"exclude_country","in":"query","required":false,"description":"Array of country codes to exclude from statistics (JSON-encoded array in query string).\nEach country code must not exceed 10 characters. Use ISO 3166-1 alpha-2 codes (e.g., \"MX\", \"FR\", \"US\").\n\nAll clicks from the specified countries will be excluded from the returned statistics.\nThis filter applies to all metrics including summary counts, traffic by countries, top referrers, etc.\n\n**Example**: `?exclude_country=[\"MX\",\"FR\"]`\n","schema":{"type":"string"},"example":"[\"MX\",\"FR\",\"US\"]"},{"name":"traffic_type","in":"query","required":false,"description":"Type of traffic counting method to use for statistics.\n\n- `unique_users` (default): Counts unique users based on IP addresses. Each IP is counted only once per period.\n- `visits`: Counts all visits individually. Each request is counted separately.\n\nThis parameter affects all statistics segments including summary metrics, traffic by countries, referrers, social traffic, daily traffic, and traffic by links/URLs.\n\n**Example**: `?traffic_type=visits`\n","schema":{"type":"string","enum":["visits","unique_users"],"default":"unique_users"},"example":"unique_users"}],"responses":{"200":{"description":"Link statistics retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkStatsResponse"},"examples":{"successful_stats":{"summary":"Successful statistics retrieval","value":{"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},{"referrer":"https://l.instagram.com/","visits":389},{"referrer":"http://m.facebook.com/","visits":142}],"button_clicks":[{"url":"https://example.com/signup","btn_id":"abc123","clicks":5,"lastClick":"2025-12-04 13:13:02.839","position":0,"btn_v":"46"},{"url":"https://example.com/pricing","btn_id":"def456","clicks":3,"lastClick":"2025-12-04 12:45:30.456","position":1,"btn_v":"46"},{"url":"https://example.com/learn-more","btn_id":null,"clicks":2,"lastClick":"2025-12-04 10:20:18.789","position":null,"btn_v":null}]}}}}}}},"400":{"description":"Bad request - Invalid parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_date_range":{"summary":"Invalid date range","value":{"success":false,"error":{"code":"INVALID_DATE_RANGE","message":"The 'from' date must be before the 'to' date"}}},"missing_parameters":{"summary":"Missing required parameters","value":{"success":false,"error":{"code":"MISSING_PARAMETERS","message":"Both 'from' and 'to' parameters are required"}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_api_key":{"summary":"Invalid API key","value":{"success":false,"error":{"code":"INVALID_API_KEY","message":"Invalid or missing API key"}}}}}}},"403":{"description":"Forbidden - Missing required permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"permission_denied":{"summary":"Permission denied","value":{"success":false,"error":{"code":"PERMISSION_DENIED","message":"API key lacks statistics.read_link permission"}}}}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"link_not_found":{"summary":"Link not found","value":{"success":false,"error":{"code":"LINK_NOT_FOUND","message":"The requested link was not found"}}}}}}},"405":{"description":"Method not allowed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MethodNotAllowedError"},"examples":{"put_not_allowed":{"summary":"PUT method not allowed","value":{"success":false,"error":"Method not allowed","status":405,"message":"HTTP method 'PUT' is not supported for this endpoint","allowedMethods":["GET"],"attemptedMethod":"PUT","timestamp":"2025-10-08T10:30:00.000Z","hint":"Try using GET to retrieve statistics"}}}}}}}}},"/api/v1/trending-links":{"get":{"tags":["Trending Links"],"summary":"Get trending links","description":"Compares link traffic over two consecutive time windows (current vs. previous) to detect spikes, rising trends, and declining links within a project.\n\nReturns per-link metrics, user category breakdowns, country data, and a computed **spike score** used for ranking.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `statistics.read_project`\n\nThe project is resolved from the API key — there is nothing to pass. A\n`project_id` query param is still accepted for signature compatibility\nwith the dashboard route, but it must match the key's own project;\nanything else is a `403`.\n\n## How It Works\n\nThe API splits recent traffic into two consecutive windows of equal duration (defined by `trending_interval`). For example, with `trending_interval=6h`:\n- **Current window**: last 6 hours\n- **Previous window**: 6–12 hours ago\n\nEach link is then scored and classified based on the change between windows.\n\n## Spike Score Algorithm\n\nThe spike score is a composite metric used to rank links by \"trendiness\". It balances absolute growth, relative growth, and volume:\n\n```\nspike_score = (absoluteComponent × 0.4) + (relativeComponent × 0.4) + (volumeBonus × 0.2)\n```\n\n| Component | Weight | Formula | Purpose |\n|-----------|--------|---------|---------|\n| absoluteComponent | 40% | `sqrt(absoluteChange)` | Prevents huge volumes from dominating |\n| relativeComponent | 40% | `percentChange / 100`, capped at 10 | Prevents tiny-base links from inflating scores |\n| volumeBonus | 20% | `log2(currentVisits)` | Gives slight edge to higher-volume links |\n\n## Trend Classification\n\nApplied in order (first match wins):\n\n| Trend | Condition |\n|-------|-----------|\n| `new` | previous == 0 AND current > 0 |\n| `spike` | percentChange >= 200% AND current >= 20 |\n| `rising` | percentChange >= 50% |\n| `stable` | percentChange >= -30% |\n| `declining` | percentChange >= -60% |\n| `dropping` | percentChange < -60% |\n\n## User Categories\n\nCategories are mutually exclusive:\n\n| Category | Conditions | Description |\n|----------|------------|-------------|\n| `normal` | prx=0, vpn=0 | Regular human visitor |\n| `proxy` | prx=1, vpn=0 | Human via proxy only |\n| `vpn` | prx=0, vpn=1 | Human via VPN only |\n| `proxy_vpn` | prx=1, vpn=1 | Human via both proxy and VPN |\n| `bots` | bot=1 | Bot traffic |\n| `spam` | spam=1 | Spam traffic |\n\n> `current_visits` = `current_normal + current_proxy + current_vpn + current_proxy_vpn + current_bots + current_spam`\n","operationId":"getTrendingLinks","parameters":[{"name":"project_id","in":"query","required":false,"description":"Optional. Resolved from the API key when omitted — which is the\nnormal way to call this endpoint. Sending a different project's id\nreturns `403`.\n","schema":{"type":"string"},"example":"abc123"},{"name":"trending_interval","in":"query","required":false,"description":"Comparison window duration. Defines the size of both the current and previous windows.\n\n- `3h`: Compare last 3 hours vs. 3–6 hours ago\n- `6h` (default): Compare last 6 hours vs. 6–12 hours ago\n- `12h`: Compare last 12 hours vs. 12–24 hours ago\n- `24h`: Compare last 24 hours vs. 24–48 hours ago\n","schema":{"type":"string","enum":["3h","6h","12h","24h"],"default":"6h"},"example":"6h"}],"responses":{"200":{"description":"Trending links retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrendingLinksResponse"},"examples":{"successful_trending":{"summary":"Successful trending links retrieval","value":{"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","u":"https://example.com/landing-page","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}}}}}}},"400":{"description":"Bad request - Missing or invalid parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_interval":{"summary":"Unsupported trending_interval","value":{"error":"Invalid trending_interval","message":"trending_interval must be one of: 3h, 6h, 12h, 24h"}}}}}},"403":{"description":"Forbidden - Missing required permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"permission_denied":{"summary":"The API key lacks statistics.read_project","value":{"error":"Permission denied","message":"This API key does not have permission to read project statistics. Please check your API key permissions."}},"wrong_project":{"summary":"project_id points at another project","value":{"error":"Permission denied","message":"This API key is scoped to a different project. Omit `project_id` — it is resolved from the key."}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"server_error":{"summary":"Database or internal error","value":{"success":false,"error":"Internal server error"}}}}}}}}},"/api/v1/social-networks":{"get":{"tags":["Social Networks"],"summary":"List social network accounts","description":"Returns all social network accounts for the project, with their latest metrics from ClickHouse.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n\n**Rate limit**: 2 requests per second\n\n**Supported platforms**: Instagram, Twitter/X, TikTok, YouTube, Reddit, Threads, Telegram, Facebook, Snapchat.\n","operationId":"listSocialNetworks","parameters":[{"name":"folder_id","in":"query","required":false,"description":"Filter by folder ID","schema":{"type":"string"},"example":"64f1a2b3c4d5e6f7a8b9c0d1"},{"name":"include_last_post","in":"query","required":false,"description":"Include the date of the most recent post for each account","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Social network accounts retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworksListResponse"},"examples":{"successful_list":{"summary":"Successful list retrieval","value":{"social_networks":[{"_id":"64f1a2b3c4d5e6f7a8b9c0d1","type":"instagram","handle":"example_account","display_name":"Example Account","url":"https://instagram.com/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,"profile_picture_url":"https://cdn.example.com/pic.jpg","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"}]}}}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"permission_denied":{"summary":"Missing social_networks.read permission","value":{"error":"Permission denied","message":"This API key does not have permission to read social networks. Please check your API key permissions."}}}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"rate_limited":{"summary":"Rate limit exceeded","value":{"error":"Rate limit exceeded: max 2 requests per second"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social-networks/{social_id}":{"get":{"tags":["Social Networks"],"summary":"Get social network detail","description":"Returns a single social network account with its latest metrics and recent history (last 30 snapshots).\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n","operationId":"getSocialNetworkDetail","parameters":[{"name":"social_id","in":"path","required":true,"description":"The unique identifier of the social network account","schema":{"type":"string"},"example":"64f1a2b3c4d5e6f7a8b9c0d1"}],"responses":{"200":{"description":"Social network detail retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworkDetailResponse"},"examples":{"successful_detail":{"summary":"Successful detail retrieval","value":{"social_network":{"_id":"64f1a2b3c4d5e6f7a8b9c0d1","type":"instagram","handle":"example_account","display_name":"Example Account","url":"https://instagram.com/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"}]}}}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Social network not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"not_found":{"summary":"Account not found","value":{"error":"Not found","message":"Social network not found or does not belong to this project"}}}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social-networks/{social_id}/history":{"get":{"tags":["Social Networks"],"summary":"Get account metrics history","description":"Returns the metrics history (followers, posts, engagement) over time for a social network account.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n","operationId":"getSocialNetworkHistory","parameters":[{"name":"social_id","in":"path","required":true,"description":"The unique identifier of the social network account","schema":{"type":"string"},"example":"64f1a2b3c4d5e6f7a8b9c0d1"},{"name":"from","in":"query","required":false,"description":"Start date (ISO 8601 format)","schema":{"type":"string","format":"date-time"},"example":"2024-01-01T00:00:00Z"},{"name":"to","in":"query","required":false,"description":"End date (ISO 8601 format)","schema":{"type":"string","format":"date-time"},"example":"2024-06-01T00:00:00Z"},{"name":"limit","in":"query","required":false,"description":"Maximum number of results (1-1000)","schema":{"type":"integer","minimum":1,"maximum":1000,"default":30}}],"responses":{"200":{"description":"Account history retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworkHistoryResponse"},"examples":{"successful_history":{"summary":"Successful history retrieval","value":{"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"}]}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_limit":{"summary":"Invalid limit parameter","value":{"error":"Validation failed","details":[{"field":"limit","message":"\"limit\" must be less than or equal to 1000"}]}}}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Social network not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social-networks/{social_id}/posts":{"get":{"tags":["Social Networks"],"summary":"List posts with metrics","description":"Returns paginated posts for a social network account, enriched with the latest metrics from ClickHouse.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n","operationId":"listSocialNetworkPosts","parameters":[{"name":"social_id","in":"path","required":true,"description":"The unique identifier of the social network account","schema":{"type":"string"},"example":"64f1a2b3c4d5e6f7a8b9c0d1"},{"name":"limit","in":"query","required":false,"description":"Items per page (1-100)","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"offset","in":"query","required":false,"description":"Number of items to skip","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Posts retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworkPostsListResponse"},"examples":{"successful_posts":{"summary":"Successful posts retrieval","value":{"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}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalid_limit":{"summary":"Invalid limit parameter","value":{"error":"Validation failed","details":[{"field":"limit","message":"\"limit\" must be less than or equal to 100"}]}}}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Social network not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social-networks/{social_id}/posts/{post_id}":{"get":{"tags":["Social Networks"],"summary":"Get post detail","description":"Returns full detail for a single post including latest metrics, metrics history, social network info, and connected links.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n","operationId":"getSocialNetworkPostDetail","parameters":[{"name":"social_id","in":"path","required":true,"description":"The unique identifier of the social network account","schema":{"type":"string"},"example":"64f1a2b3c4d5e6f7a8b9c0d1"},{"name":"post_id","in":"path","required":true,"description":"The unique identifier of the post","schema":{"type":"string"},"example":"65a1b2c3d4e5f6a7b8c9d0e1"}],"responses":{"200":{"description":"Post detail retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworkPostDetailResponse"},"examples":{"successful_detail":{"summary":"Successful post detail retrieval","value":{"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":[]}}}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social-networks/{social_id}/posts/{post_id}/history":{"get":{"tags":["Social Networks"],"summary":"Get post metrics history","description":"Returns the metrics history over time for a specific post.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n","operationId":"getSocialNetworkPostHistory","parameters":[{"name":"social_id","in":"path","required":true,"description":"The unique identifier of the social network account","schema":{"type":"string"},"example":"64f1a2b3c4d5e6f7a8b9c0d1"},{"name":"post_id","in":"path","required":true,"description":"The unique identifier of the post","schema":{"type":"string"},"example":"65a1b2c3d4e5f6a7b8c9d0e1"},{"name":"from","in":"query","required":false,"description":"Start date (ISO 8601 format)","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"description":"End date (ISO 8601 format)","schema":{"type":"string","format":"date-time"}},{"name":"limit","in":"query","required":false,"description":"Maximum number of results (1-1000)","schema":{"type":"integer","minimum":1,"maximum":1000,"default":30}}],"responses":{"200":{"description":"Post history retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworkPostHistoryResponse"},"examples":{"successful_history":{"summary":"Successful post history retrieval","value":{"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"}]}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social-networks/analytics":{"get":{"tags":["Social Networks"],"summary":"Get aggregated analytics","description":"Returns project-level aggregated analytics: total followers, growth, daily breakdowns, and per-account evolution.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n","operationId":"getSocialNetworkAnalytics","parameters":[{"name":"days","in":"query","required":false,"description":"Lookback period in days (1-90)","schema":{"type":"integer","minimum":1,"maximum":90,"default":30}}],"responses":{"200":{"description":"Analytics retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworkAnalyticsResponse"},"examples":{"successful_analytics":{"summary":"Successful analytics retrieval","value":{"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","profile_picture_url":"https://cdn.example.com/pic.jpg","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}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social-networks/trending":{"get":{"tags":["Social Networks"],"summary":"Get trending posts","description":"Returns posts showing significant engagement growth over a recent period. Posts must have at least 3 analysis snapshots to qualify.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n","operationId":"getSocialNetworkTrending","parameters":[{"name":"days","in":"query","required":false,"description":"Lookback period in days (1-90)","schema":{"type":"integer","minimum":1,"maximum":90,"default":7}},{"name":"limit","in":"query","required":false,"description":"Maximum number of results (1-100)","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"social_network_id","in":"query","required":false,"description":"Filter by a specific social network account ID","schema":{"type":"string"},"example":"64f1a2b3c4d5e6f7a8b9c0d1"}],"responses":{"200":{"description":"Trending posts retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworkTrendingResponse"},"examples":{"successful_trending":{"summary":"Successful trending posts retrieval","value":[{"_id":"65a1b2c3d4e5f6a7b8c9d0e1","post_id":"CxY1234567","social_network_id":"64f1a2b3c4d5e6f7a8b9c0d1","text":"This post went viral...","url":"https://instagram.com/p/CxY1234567","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}]}]}}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social-networks/folders":{"get":{"tags":["Social Networks"],"summary":"List social network folders","description":"Returns all social network folders for the project.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n","operationId":"listSocialNetworkFolders","responses":{"200":{"description":"Folders retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworkFoldersResponse"},"examples":{"successful_folders":{"summary":"Successful folders retrieval","value":{"folders":[{"_id":"64f1a2b3c4d5e6f7a8b9c0d1","name":"Instagram Accounts","project_id":"proj_abc123","social_count":3,"created_at":"2024-01-15T10:00:00.000Z"}]}}}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social-networks/folders/{folder_id}/stats":{"get":{"tags":["Social Networks"],"summary":"Get folder stats","description":"Returns aggregated stats for all social networks within a folder, broken down by platform.\n\n**Authorization**: Bearer API key\n\n**Required permission**: `social_networks.read`\n","operationId":"getSocialNetworkFolderStats","parameters":[{"name":"folder_id","in":"path","required":true,"description":"The unique identifier of the folder","schema":{"type":"string"},"example":"64f1a2b3c4d5e6f7a8b9c0d1"}],"responses":{"200":{"description":"Folder stats retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialNetworkFolderStatsResponse"},"examples":{"successful_stats":{"summary":"Successful folder stats retrieval","value":{"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"}}}}}},"403":{"description":"Permission denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Folder not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"Enter your API key in the format: Bearer your_api_key_here"}},"schemas":{"Error":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string","example":"An error occurred"},"status":{"type":"integer","example":400},"message":{"type":"string","example":"Detailed error message"},"timestamp":{"type":"string","format":"date-time","example":"2025-10-08T10:30:00.000Z"},"hint":{"type":"string","example":"Try checking your input parameters"}}},"ValidationError":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string","example":"Validation failed"},"status":{"type":"integer","example":400},"details":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","example":"url"},"message":{"type":"string","example":"URL must be a valid URI"}}}},"timestamp":{"type":"string","format":"date-time","example":"2025-10-08T10:30:00.000Z"}}},"MethodNotAllowedError":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string","example":"Method not allowed"},"status":{"type":"integer","example":405},"message":{"type":"string","example":"HTTP method 'POST' is not supported for this endpoint"},"allowedMethods":{"type":"array","items":{"type":"string"},"example":["GET","PUT"]},"attemptedMethod":{"type":"string","example":"POST"},"timestamp":{"type":"string","format":"date-time","example":"2025-10-08T10:30:00.000Z"},"hint":{"type":"string","example":"Try using one of these methods: GET, PUT"}}},"CreateLinkRequest":{"type":"object","required":["type","u","domain"],"properties":{"type":{"type":"string","enum":["l_p","d_l"],"description":"Link type - 'l_p' for landing page or 'd_l' for direct link.\nIMPORTANT: This field is required and determines the link behavior:\n- 'l_p': Creates a landing page (bio link)\n- 'd_l': Creates a direct link (URL redirect)\nWhen 'type' is 'd_l', the 'url' field becomes required.\n","example":"l_p"},"u":{"type":"string","description":"Username or unique identifier (can be empty string)","example":"johndoe"},"domain":{"type":"string","description":"Domain for the link","example":"link.dm"},"url":{"type":"string","description":"Target URL to redirect to.\nREQUIRED when type is 'd_l' (direct link).\nOptional when type is 'l_p' (landing page).\n","format":"uri","example":"https://example.com"},"n":{"type":"string","description":"Display name (optional, can be empty)","example":"John Doe"},"bio":{"type":"string","description":"Bio or description text (optional, can be empty)","example":"Professional developer and designer"},"links":{"type":"array","description":"Array of link objects for the landing page","items":{"type":"object","properties":{"title":{"type":"string","example":"Portfolio"},"url":{"type":"string","format":"uri","example":"https://johndoe.com"}}}},"pp":{"type":"object","properties":{"url":{"type":"string","description":"Profile picture URL (can be empty)","example":"https://example.com/profile.jpg"},"enabled":{"type":"boolean","description":"Whether to show profile picture","example":true}}},"cover":{"type":"object","properties":{"url":{"type":"string","description":"Cover image URL (can be empty)","example":"https://example.com/cover.jpg"},"enabled":{"type":"boolean","description":"Whether to show cover image","example":true},"height":{"type":"number","description":"Cover image height in pixels","example":200}}},"background":{"type":"object","properties":{"type":{"type":"string","enum":["color","url"],"description":"Background type","example":"color"},"color":{"type":"string","description":"Background color (when type is 'color')","example":"#ffffff"},"url":{"type":"string","description":"Background image URL (when type is 'url')","example":"https://example.com/bg.jpg"}}},"background_color":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Enable background color","example":true},"color":{"type":"string","description":"Color value","example":"#ffffff"}}},"background_url":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Enable background image","example":false},"url":{"type":"string","description":"Image URL (can be empty)","example":""},"blur":{"type":"number","description":"Blur effect intensity","example":0}}},"cover_gradiant":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Enable gradient overlay","example":false},"size":{"type":"number","description":"Gradient size","example":0}}},"template":{"type":"string","description":"Template identifier","example":"default"},"cs_template":{"type":"string","description":"**Landing template** id — the template rendered for a landing-page link\n(`type: \"l_p\"`). Get valid ids from `GET /api/v1/templates?kind=landing`.\n\nSend `null` to detach. An inline Page Builder v2 page on the link takes\nprecedence over this template.\n","example":"507f1f77bcf86cd799439011"},"cs_1-step":{"type":"string","description":"**First-step template** id — skins the 1-step verification gate. Get valid\nids from `GET /api/v1/templates?kind=first_step`. Send `null` to detach.\n\nThis sets the gate's design only; the gate is switched on through\n`1-step-verification-page.enable`.\n","example":"692071e1515affc61c701abf"},"cs_3dots_template":{"type":"string","description":"**3-dots template** id for the *direct* \"open in browser\" escape overlay\n(the one shown on arrival). Get valid ids from\n`GET /api/v1/templates?kind=three_dots`. Send `null` to detach.\n","example":"69df9e86d30eb3d7dff9d2ad"},"cs_3dots_click_template":{"type":"string","description":"**3-dots template** id for the *click* overlay (shown when a visitor taps\na link button). Independent from `cs_3dots_template`.\nSend `null` to detach.\n","example":"69df9e86d30eb3d7dff9d2ad"},"title_options":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Enable custom title styling","example":false},"f_s":{"type":"number","description":"Font size","example":16},"font_family":{"type":"string","description":"Font family","example":"Arial"},"letter_spacing":{"type":"number","description":"Letter spacing","example":0},"color":{"type":"string","description":"Text color","example":"#000000"}}},"username_display_options":{"type":"object","properties":{"hide_username":{"type":"boolean","description":"Hide the username","example":false},"font_family":{"type":"string","description":"Font family for username","example":"Arial"}}},"s":{"type":"object","description":"Social media settings","additionalProperties":true},"leads":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Enable lead form","example":false},"fields":{"type":"object","properties":{"name":{"type":"boolean","description":"Include name field","example":true},"email":{"type":"boolean","description":"Include email field","example":true},"phone":{"type":"boolean","description":"Include phone field","example":false},"username":{"type":"boolean","description":"Include username field","example":false},"notes":{"type":"boolean","description":"Include notes field","example":false}}},"title":{"type":"string","description":"Form title (can be empty)","example":"Get in touch"},"button_text":{"type":"string","description":"Submit button text (can be empty)","example":"Submit"},"success_message":{"type":"string","description":"Success message (can be empty)","example":"Thank you for your submission!"}}},"tracking":{"type":"object","properties":{"facebook_pixel_id":{"type":"string","description":"Facebook Pixel ID (can be empty)","example":""}}},"geo_rules":{"type":"array","description":"**Geo Filter** — per-visitor overrides evaluated before the link is served.\nBlock a country, redirect a region to a localized destination, or serve a\ndifferent landing page per language.\n\nOnly the single highest-priority match is applied, and a matched rule\nsuppresses A/B test flows for that visitor. See the **Geo Filters** section\nof the introduction for the full model and worked examples.\n","items":{"type":"object","description":"A single **Geo Filter** rule: \"when the visitor is X, serve Y instead\".\n\nA rule is a *partial link override*. When it matches, its fields are merged\nonto the link before the page is served, so the visitor gets the rule's\ndestination, template or block screen rather than the link's own.\n\n**Only one rule is ever applied.** Every enabled rule is evaluated, then the\nsingle highest-priority match wins:\n\n| Priority | Rule shape |\n|---|---|\n| 3 (highest) | `detection_type: ip` **plus** `regions` and/or `cities` |\n| 2 | `detection_type: browser_language` |\n| 1 (lowest) | `detection_type: ip` alone |\n\nTies are broken by array order — the rule that appears first in `geo_rules`\nwins. Visitors matching no rule are served the link normally.\n\n> **A matched geo rule suppresses A/B test flows for that visitor.** Geo\n> targeting and A/B testing on the same link do not compose: geo wins.\n\n> **A rule with `enabled: false` is skipped entirely.** When you omit the\n> field the API stores `enabled: true` for you, so a rule is never created\n> dormant by accident.\n","properties":{"id":{"type":"string","description":"Stable identifier for the rule, used by the dashboard editor to target it.\nGenerated automatically when you omit it — supply your own if you want to\ncorrelate rules with your system.\n","example":"eu-visitors"},"enabled":{"type":"boolean","default":true,"description":"Whether the rule participates in matching. A disabled rule is skipped\nbefore any other field is looked at. Defaults to `true`.\n","example":true},"detection_type":{"type":"string","enum":["ip","browser_language"],"default":"ip","description":"How the visitor is identified.\n\n- `ip` — geolocation of the visitor's IP address. Match on `location` or\n  `countries`, optionally narrowed by `regions` / `cities`.\n- `browser_language` — the language the visitor's browser advertises.\n  Match on `language`.\n","example":"ip"},"t":{"type":"string","enum":["block","d_l","l_p"],"default":"d_l","description":"What happens to a matched visitor.\n\n- `block` — serve a hard `404`. No other action field is needed.\n- `d_l` — redirect to the rule's `url`. **`url` is required.**\n- `l_p` — serve a landing page. Resolved in this order:\n  `landing_v2_page` → `cs_template` → `url`. **At least one is required.**\n","example":"block"},"location":{"type":"string","description":"A single ISO-3166-1 alpha-2 country code (`\"FR\"`, `\"US\"`), **or** one of\nthe built-in group keys, which expand to every country in the group:\n\n`AFRICA`, `MIDDLE_EAST`, `EUROPE`, `ASIA`, `NORTH_AMERICA`,\n`SOUTH_AMERICA`, `OCEANIA`, `LOW_GDP_PER_CAPITA`\n\nAn `ip` rule requires `location` **or** a non-empty `countries`.\n","example":"FR"},"countries":{"type":"array","description":"ISO-3166-1 alpha-2 codes. The rule matches if the visitor is in **any** of\nthem. Use this instead of `location` to target a hand-picked set of\ncountries. Codes are stored uppercase.\n","items":{"type":"string","minLength":2,"maxLength":2},"example":["US","CA","MX"]},"regions":{"type":"array","description":"Narrows an `ip` match to these regions/states inside the matched country.\nCompared case-insensitively against the visitor's resolved region.\n\nAdding `regions` (or `cities`) raises the rule to the highest priority\ntier — a country-wide rule will no longer beat it.\n\nIgnored on a `browser_language` rule; sending it there is rejected.\n","items":{"type":"string"},"example":["Ile-de-France"]},"cities":{"type":"array","description":"Narrows an `ip` match to these cities. Accepts either a plain city name or\na geocoder result object — for objects, `name`, `place_name` and `text`\nare all compared, as is the part before the first comma (so\n`\"Lyon, France\"` matches a visitor in `Lyon`).\n\nA rule with both `regions` and `cities` matches if **either** hits.\n\nIgnored on a `browser_language` rule; sending it there is rejected.\n","items":{"oneOf":[{"type":"string","example":"Paris"},{"type":"object","properties":{"name":{"type":"string","example":"Lyon"},"place_name":{"type":"string","example":"Lyon, France"},"text":{"type":"string","example":"Lyon"}}}]},"example":["Paris","Marseille"]},"language":{"type":"string","description":"Browser language tag, matched as a **case-insensitive substring** of the\nvisitor's language. This means `\"fr\"` also matches `fr-CA` and `fr-BE`,\nwhile `\"fr-CA\"` matches only Canadian French.\n\nRequired when `detection_type` is `browser_language`.\n","example":"fr"},"url":{"type":"string","format":"uri","description":"Destination for the matched visitor. Required when `t` is `d_l`; also\naccepted as the last-resort target of an `l_p` rule.\n","example":"https://example.com/fr"},"cs_template":{"type":"string","description":"ObjectId of a project template served to matched visitors when `t` is\n`l_p`. The template is fetched at serve time, so edits to it apply to the\ngeo rule immediately.\n","example":"507f1f77bcf86cd799439011"},"landing_v2_page":{"type":"object","description":"A self-contained Page Builder v2 page served to matched visitors when `t`\nis `l_p`. Takes precedence over `cs_template`. Must contain at least one\nentry in `sections` to be considered configured.\n","properties":{"sections":{"type":"array","description":"Page Builder v2 sections.","items":{"type":"object","additionalProperties":true}}}}},"additionalProperties":true},"example":[{"detection_type":"ip","location":"FR","t":"block"},{"detection_type":"ip","countries":["US","CA"],"t":"d_l","url":"https://example.com/north-america"}]},"shield":{"type":"boolean","description":"Master Shield switch. Links created through the API have Shield on by\ndefault; Shield never runs while this is false.\n","default":true,"example":true},"enabled":{"type":"boolean","description":"Whether the link is active","example":true},"note":{"type":"string","description":"Internal note (can be empty)","example":""},"dynamic_informations":{"type":"object","description":"Dynamic informations allow overriding specific template properties (name and profile picture) \nwhile maintaining the template's design. Only works when cs_template is specified.\n","properties":{"enabled":{"type":"boolean","description":"Master toggle to enable/disable dynamic informations override","example":true},"pp_enabled":{"type":"boolean","description":"Toggle to enable/disable profile picture override specifically","example":true},"n":{"type":"string","description":"Display name that overrides the template's name","example":"John Doe"},"pp":{"type":"object","description":"Profile picture object with customization options","properties":{"url":{"type":"string","description":"URL of the profile picture","example":"https://cdn.example.com/john-profile.jpg"},"enabled":{"type":"boolean","description":"Whether the profile picture is displayed","example":true},"hide_section":{"type":"boolean","description":"Hide the entire profile picture section","example":false},"size":{"type":"number","minimum":60,"maximum":280,"description":"Size of the profile picture in pixels","example":150},"border":{"type":"object","description":"Border styling options","properties":{"color":{"type":"string","description":"Border color (hex, rgb, etc.)","example":"#000000"},"style":{"type":"string","enum":["none","solid","dashed","dotted","double","groove","ridge","inset","outset"],"description":"Border style","example":"solid"},"width":{"type":"number","minimum":0,"maximum":24,"description":"Border width in pixels","example":2},"sides":{"type":"object","description":"Control which sides have borders","additionalProperties":true},"inner":{"type":"object","description":"Inner border for double border effect","additionalProperties":true}}}}}}},"shield_preset":{"type":"string","enum":["instagram","bots_only","hard_404"],"description":"**Applied on create.** Naming a preset here builds the whole protection\nprofile in the same call — its rules and its default block screen — so a\nsingle `PUT /api/v1/links` yields a protected link. Send `shield_rules`\ninstead (or as well) to define the rules yourself; explicit rules win.\nSee `GET /api/v1/shield/presets`.\n","example":"instagram"},"shield_rules":{"type":"array","description":"**Shield V2** — the ordered rule list for the new link. Same validation and\ndecoy resolution as the dedicated Shield resource.\n","items":{"$ref":"#/components/schemas/ShieldRule"}},"shield_block":{"type":"object","description":"**Shield V2** — the default block screen for the new link. A decoy landing\nwith nothing to render is rejected with 400 rather than silently serving a\n404.\n","allOf":[{"$ref":"#/components/schemas/ShieldBlockScreen"}]},"folder_id":{"type":"string","description":"**File the new link straight into a folder.** Id from\n`GET /api/v1/folders` (or from `POST /api/v1/folders`). Without it the\nlink is created outside every folder.\n\nThis is the shorthand for the common single-folder case; it is the same\nfield as `folders`, and the two cannot be sent together. Send `null` for\n\"no folder\" (the default).\n\nA folder id from another project is rejected with `400`.\n","example":"6a7491caaa4bce8130ea5a62"},"folders":{"type":"array","description":"Full folder membership — a link may sit in several folders at once. Use\nthis instead of `folder_id` when you need more than one; sending both is a\n`400`.\n\nEchoed back on the response as the same array of id strings.\n","items":{"type":"string"},"example":["6a7491caaa4bce8130ea5a62","69ae5d49fc9f868657a25902"]},"privacy":{"type":"object","description":"**Private link** — the *Privacy* setting of the dashboard, applied on\ncreate. `{\"links\": true}` makes the new link private: every outbound\ndestination it renders is replaced by a short same-origin redirect\n(`https://<your-domain>/go/<token>`), so the real URL never appears in the\nHTML a crawler or link scanner sees. Omit it (or send `{\"links\": false}`)\nfor a normal link.\n\nNothing else to switch on: the `/go/` redirects for the new link are\nprovisioned by this same call. See the **Link Privacy** section of the\nintroduction.\n","properties":{"links":{"type":"boolean","description":"`true` = Private, `false` = Normal (the default).","example":true}},"example":{"links":true}}}},"UpdateLinkRequest":{"type":"object","description":"Request body for updating a link. All fields are optional - only provided fields will be updated.","properties":{"url":{"type":"string","description":"Target URL to redirect to (for direct links)","format":"uri","example":"https://new-example.com"},"u":{"type":"string","description":"Username or unique identifier","example":"newusername"},"n":{"type":"string","description":"Display name","example":"New Name"},"bio":{"type":"string","description":"Bio or description text","example":"Updated bio text"},"links":{"type":"array","description":"Array of link objects for the landing page","items":{"type":"object","properties":{"title":{"type":"string","example":"Portfolio"},"url":{"type":"string","format":"uri","example":"https://johndoe.com"}}}},"pp":{"type":"object","properties":{"url":{"type":"string","description":"Profile picture URL","example":"https://example.com/new-profile.jpg"},"enabled":{"type":"boolean","description":"Whether to show profile picture","example":true}}},"cover":{"type":"object","properties":{"url":{"type":"string","description":"Cover image URL","example":"https://example.com/new-cover.jpg"},"enabled":{"type":"boolean","description":"Whether to show cover image","example":true},"height":{"type":"number","description":"Cover image height in pixels","example":250}}},"background":{"type":"object","properties":{"type":{"type":"string","enum":["color","url"],"description":"Background type","example":"color"},"color":{"type":"string","description":"Background color","example":"#f0f0f0"},"url":{"type":"string","description":"Background image URL","example":"https://example.com/bg.jpg"}}},"template":{"type":"string","description":"Template identifier","example":"modern"},"geo_rules":{"type":"array","description":"**Geo Filter** — per-visitor overrides evaluated before the link is served.\n\nThis field is **replace-the-whole-list**, not a merge: the array you send\nbecomes the link's complete rule set. To add a rule, send the existing\nrules plus the new one (read them back from `GET /api/v1/links/{id}`).\nTo remove every rule, send `[]`.\n\nEach write stamps `geo_rules_updated_at`. See the **Geo Filters** section\nof the introduction for the full model and worked examples.\n","items":{"$ref":"#/components/schemas/CreateLinkRequest/properties/geo_rules/items"},"example":[{"detection_type":"browser_language","language":"fr","t":"d_l","url":"https://example.com/fr"}]},"geolocation_enabled":{"type":"boolean","deprecated":true,"description":"**Deprecated and ignored.** This field was never read by the serve layer —\nsetting it did nothing. Use `geo_rules` instead. Still accepted for\nbackwards compatibility, but it is discarded and never stored.\n"},"geolocation_redirects":{"type":"array","deprecated":true,"description":"**Deprecated and ignored.** Never read by the serve layer — links\nconfigured with it were not geo-targeted at all. Use `geo_rules` instead\n(a `{ countries, url }` entry becomes a rule with\n`t: \"d_l\"`). Still accepted for backwards compatibility, but discarded and\nnever stored.\n","items":{"type":"object","properties":{"countries":{"type":"array","items":{"type":"string"}},"url":{"type":"string","format":"uri"}}}},"shield":{"type":"boolean","description":"Master Shield switch for this link. Shield never runs while this is false.\n","example":true},"enabled":{"type":"boolean","description":"Whether the link is active","example":true},"note":{"type":"string","description":"Internal note","example":"Updated via API"},"cs_template":{"type":"string","description":"**Landing template** id — the template rendered for a landing-page link\n(`t: \"l_p\"`). Get valid ids from `GET /api/v1/templates?kind=landing`.\nSend `null` to detach.\n\nThe equivalent `PUT|PATCH /api/v1/links/{link_id}/dynamic-overrides`\nresource remains the surface to use when you want to set the template and\nits per-link `dynamic_informations` / `dynamic_links` in one call.\n","example":"507f1f77bcf86cd799439011"},"cs_1-step":{"type":"string","description":"**First-step template** id — skins the 1-step verification gate.\n`GET /api/v1/templates?kind=first_step`. Send `null` to detach.\n","example":"692071e1515affc61c701abf"},"cs_3dots_template":{"type":"string","description":"**3-dots template** id for the *direct* \"open in browser\" escape overlay.\n`GET /api/v1/templates?kind=three_dots`. Send `null` to detach.\n","example":"69df9e86d30eb3d7dff9d2ad"},"cs_3dots_click_template":{"type":"string","description":"**3-dots template** id for the *click* overlay. Independent from\n`cs_3dots_template`. Send `null` to detach.\n","example":"69df9e86d30eb3d7dff9d2ad"},"dynamic_informations":{"type":"object","description":"Dynamic informations allow overriding specific template properties (name and profile picture)\nwhile maintaining the template's design. Only has an effect when the link\nhas a `cs_template` attached — set it in the same call, or through\n`PUT|PATCH /api/v1/links/{link_id}/dynamic-overrides`.\n","properties":{"enabled":{"type":"boolean","description":"Master toggle to enable/disable dynamic informations override","example":true},"pp_enabled":{"type":"boolean","description":"Toggle to enable/disable profile picture override specifically","example":true},"n":{"type":"string","description":"Display name that overrides the template's name","example":"Jane Smith"},"pp":{"type":"object","description":"Profile picture object with customization options","properties":{"url":{"type":"string","description":"URL of the profile picture","example":"https://cdn.example.com/jane-profile.jpg"},"enabled":{"type":"boolean","description":"Whether the profile picture is displayed","example":true},"hide_section":{"type":"boolean","description":"Hide the entire profile picture section","example":false},"size":{"type":"number","minimum":60,"maximum":280,"description":"Size of the profile picture in pixels","example":120},"border":{"type":"object","description":"Border styling options","properties":{"color":{"type":"string","description":"Border color (hex, rgb, etc.)","example":"#4A90E2"},"style":{"type":"string","enum":["none","solid","dashed","dotted","double","groove","ridge","inset","outset"],"description":"Border style","example":"solid"},"width":{"type":"number","minimum":0,"maximum":24,"description":"Border width in pixels","example":3},"sides":{"type":"object","description":"Control which sides have borders","additionalProperties":true},"inner":{"type":"object","description":"Inner border for double border effect","additionalProperties":true}}}}}}},"shield_rules":{"type":"array","description":"**Shield V2** — the ordered rule list, replacing whatever is stored.\nValidated exactly like the dedicated Shield resource: unknown condition\ntypes or values are rejected with the path of the offending entry, decoy\nreferences (`template_id`, `link_id`, `source: \"self\"`) are resolved\nserver-side, and a rule whose decoy has nothing to render is refused.\n\nFor presets, simple buckets and a readiness verdict, prefer\n`PUT|PATCH /api/v1/links/{link_id}/shield`.\n","items":{"$ref":"#/components/schemas/ShieldRule"}},"shield_block":{"type":"object","description":"**Shield V2** — the link's default block screen, inherited by every rule\nthat does not carry its own.\n","allOf":[{"$ref":"#/components/schemas/ShieldBlockScreen"}]},"shield_preset":{"type":"string","enum":["instagram","bots_only","hard_404"],"description":"Records which Shield preset the configuration came from. On **update** this\nis stored as-is (cosmetic) — to actually apply a preset, send\n`{\"preset\": \"...\"}` to `PUT|PATCH /api/v1/links/{link_id}/shield`.\n","example":"instagram"},"folder_id":{"type":"string","description":"**Move the link into a folder.** Id from `GET /api/v1/folders`. Send\n`null` to take the link out of every folder.\n\nThis is the shorthand for the common single-folder case; it is the same\nfield as `folders`, and the two cannot be sent together (`400`). A folder\nid from another project is rejected with `400`.\n","example":"6a7491caaa4bce8130ea5a62"},"folders":{"type":"array","description":"Full folder membership — a link may sit in several folders at once.\n\n**This replaces the whole list**, exactly like `geo_rules`: send `[]` to\nremove the link from every folder, and include the ids you want to keep.\nRead the current value from `link.folders` on\n`GET /api/v1/links/{link_id}` first.\n","items":{"type":"string"},"example":["6a7491caaa4bce8130ea5a62","69ae5d49fc9f868657a25902"]},"privacy":{"type":"object","description":"**Private link** — the *Privacy* setting of the dashboard. Sending\n`{\"links\": true}` turns the link private: every outbound destination it\nrenders is replaced by a short same-origin redirect\n(`https://<your-domain>/go/<token>`), so the real URL never appears in the\nHTML a crawler or link scanner sees. `{\"links\": false}` restores the normal\nbehaviour.\n\nThe switch is enough — flipping it on provisions every `/go/` redirect for\nthe link before the response comes back, and later edits to the page keep\nthem in sync. See the **Link Privacy** section of the introduction.\n\nMerged into the stored object, so a privacy setting you did not send is\nleft untouched. `links` is required when `privacy` is present.\n","required":["links"],"additionalProperties":false,"properties":{"links":{"type":"boolean","description":"`true` = Private, `false` = Normal (the default).","example":true}},"example":{"links":true}}}},"LinkResponse":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"id":{"type":"string","description":"Unique identifier for the link","example":"abc123def456"},"short_url":{"type":"string","format":"uri","description":"The shortened URL","example":"https://link.dm/abc123"},"original_url":{"type":"string","format":"uri","description":"The original URL (for direct links)","example":"https://example.com"},"type":{"type":"string","enum":["l_p","d_l"],"description":"Link type","example":"l_p"},"created_at":{"type":"string","format":"date-time","description":"When the link was created","example":"2024-01-15T10:30:00Z"}}}}},"Link":{"type":"object","properties":{"id":{"type":"string","description":"Unique identifier for the link","example":"abc123def456"},"short_url":{"type":"string","format":"uri","description":"The shortened URL","example":"https://link.dm/abc123"},"original_url":{"type":"string","format":"uri","description":"The original URL","example":"https://example.com/very-long-url-that-needs-shortening"},"title":{"type":"string","nullable":true,"description":"Custom title for the link","example":"Example Website"},"description":{"type":"string","nullable":true,"description":"Description of the link","example":"A demonstration website for testing"},"tags":{"type":"array","items":{"type":"string"},"description":"Array of tags","example":["demo","testing"]},"clicks":{"type":"integer","description":"Number of times the link has been clicked","example":42},"cs_template":{"type":"string","description":"Id of the **landing template** attached to this link, when there is one.\nThe other three template slots (first-step gate, direct and click 3-dots\noverlays) are returned together with this one under `templates` on\n`GET /api/v1/links/{id}` — see *Templates — the three families*.\n","example":"507f1f77bcf86cd799439011"},"cs_1-step":{"type":"string","description":"Id of the **first-step template** skinning the link's 1-step verification\ngate, when there is one.\n","example":"692071e1515affc61c701abf"},"dynamic_informations":{"type":"object","nullable":true,"description":"Dynamic template overrides (only has an effect when cs_template is set)","properties":{"enabled":{"type":"boolean","example":true},"pp_enabled":{"type":"boolean","example":true},"n":{"type":"string","example":"Custom Name"},"pp":{"type":"object"}}},"geo_rules":{"type":"array","description":"**Geo Filter** rules attached to the link, in evaluation order.\n\nReturned in full by `GET /api/v1/links/{id}` and echoed back by\n`PUT /api/v1/links`. The **list** endpoint returns `geo_rules_count`\ninstead — a single rule can embed an entire landing page, so the rules\nthemselves would make a 50-item page enormous.\n","items":{"$ref":"#/components/schemas/CreateLinkRequest/properties/geo_rules/items"}},"geo_rules_count":{"type":"integer","description":"Number of Geo Filter rules on the link. Returned by the **list** endpoint\nin place of the rules themselves; `0` when the link has no geo targeting.\n","example":2},"geo_rules_updated_at":{"type":"string","format":"date-time","nullable":true,"description":"When the Geo Filter rules were last written — including when they were\ncleared. Absent on a link whose rules have never been touched.\n","example":"2024-01-15T10:30:00Z"},"shield":{"type":"boolean","description":"Whether **Shield** is active on this link. The full configuration (rules,\nbuckets, block screen) lives at `GET /api/v1/links/{link_id}/shield`; the\n**detail** endpoint also returns it inline under the top-level `shield`\nobject of the response.\n","example":true},"shield_preset":{"type":"string","nullable":true,"description":"The Shield preset last applied (`instagram`, `bots_only`, `hard_404`), or null.","example":"instagram"},"shield_rules_count":{"type":"integer","description":"Number of Shield rules on the link. Returned by the **list** endpoint in\nplace of the rules themselves — a rule can embed an entire decoy landing.\n","example":5},"shield_block_behavior":{"type":"string","enum":["not_found","landing","three_dots","do_nothing"],"description":"The link's default Shield block screen. Returned by the **list** endpoint.","example":"landing"},"shield_model":{"type":"string","enum":["v2","legacy"],"description":"`v2` — the ordered Shield rule engine decides. `legacy` — the link still\nruns on the pre-V2 tri-state fields. Returned by the **list** endpoint.\n","example":"v2"},"privacy":{"type":"object","description":"Whether the link is **private**. Always present on `GET /api/v1/links` and\n`GET /api/v1/links/{id}`, and echoed by `PUT /api/v1/links` — a link that\nhas never been made private reads `{\"links\": false}`.\n\nWhen `links` is `true`, the destinations the link renders are served\nthrough `https://<your-domain>/go/<token>` instead of being written into\nthe page. See the **Link Privacy** section of the introduction.\n","properties":{"links":{"type":"boolean","description":"`true` = Private, `false` = Normal.","example":true}},"example":{"links":true}},"folders":{"type":"array","description":"The folders this link belongs to, as folder ids. Always present on\n`GET /api/v1/links` and `GET /api/v1/links/{id}`, and echoed by\n`PUT /api/v1/links` and `PATCH /api/v1/links/{id}` — a link that sits\noutside every folder reads `[]`.\n\nThis is exactly the shape you may send back as `folders` on a `PATCH`.\nFolder **names** are resolved for you in the sibling `folders` object of\n`GET /api/v1/links/{id}`; on the list endpoint, join them against\n`GET /api/v1/folders`.\n","items":{"type":"string"},"example":["6a7491caaa4bce8130ea5a62"]},"created_at":{"type":"string","format":"date-time","description":"When the link was created","example":"2024-01-15T10:30:00Z"},"updated_at":{"type":"string","format":"date-time","description":"When the link was last updated","example":"2024-01-15T10:30:00Z"},"expires_at":{"type":"string","format":"date-time","nullable":true,"description":"When the link expires","example":"2024-12-31T23:59:59Z"},"is_active":{"type":"boolean","description":"Whether the link is active","example":true}}},"LinksListResponse":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"array","items":{"$ref":"#/components/schemas/Link"}},"pagination":{"type":"object","properties":{"page":{"type":"integer","example":1},"limit":{"type":"integer","example":20},"total":{"type":"integer","example":150},"pages":{"type":"integer","example":8}}}}},"LinkDetailsResponse":{"type":"object","description":"Response of `GET /api/v1/links/{id}`.\n\nNote the envelope: this endpoint returns the link under `link` (alongside the\nowning `project`), not under a `data` key.\n","properties":{"link":{"allOf":[{"$ref":"#/components/schemas/Link"}],"description":"The link, including its full `geo_rules` array. Read this before sending a\n`PATCH` that changes `geo_rules` — that field replaces the whole list.\n"},"folders":{"type":"array","description":"The folders the link is in, **with their names resolved** — so displaying\na link does not need a second call to `GET /api/v1/folders`. `[]` when the\nlink sits outside every folder.\n\nThe raw ids are also on `link.folders`; that is the field you send back to\nmove the link.\n","items":{"type":"object","properties":{"_id":{"type":"string","example":"6a7491caaa4bce8130ea5a62"},"name":{"type":"string","example":"Marketing Campaign"}}}},"templates":{"allOf":[{"$ref":"#/components/schemas/LinkTemplates"}],"description":"The template attached to each surface of the link — landing page,\nfirst-step gate, and the two 3-dots overlays. See the\n*Templates — the three families* section of the introduction.\n"},"shield":{"allOf":[{"$ref":"#/components/schemas/ShieldConfig"}],"description":"The link's **Shield** configuration, returned inline on the detail\nendpoint (without the decoy page snapshot). The same object — plus\n`?include_page=true` and the write verbs — lives at\n`/api/v1/links/{link_id}/shield`.\n"},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"LinkTemplates":{"type":"object","description":"Which template each surface of the link currently uses — one entry per\ntemplate family. `null` means no template is attached to that surface.\n\nIds are returned as plain 24-character strings so they compare directly with\nthe ids from `GET /api/v1/templates?kind=...`, and they are the same keys you\nsend back on `PUT /api/v1/links` or `PATCH /api/v1/links/{id}` to change them.\n","properties":{"cs_template":{"type":"string","description":"The **landing template** (`GET /api/v1/templates?kind=landing`) rendered\nfor a `t: \"l_p\"` link. An inline Page Builder v2 page on the link takes\nprecedence over it.\n","example":"507f1f77bcf86cd799439011"},"cs_1-step":{"type":"string","description":"The **first-step template** (`?kind=first_step`) that skins the 1-step\nverification gate. Attaching it sets the design only — the gate itself is\nswitched on through `1-step-verification-page.enable`.\n","example":"692071e1515affc61c701abf"},"cs_3dots_template":{"type":"string","description":"The **3-dots template** (`?kind=three_dots`) used by the *direct* \"open in\nbrowser\" escape overlay — the one shown on arrival.\n","example":"69df9e86d30eb3d7dff9d2ad"},"cs_3dots_click_template":{"type":"string","description":"The **3-dots template** used by the *click* overlay — the one shown when a\nvisitor taps a link button. Independent from `cs_3dots_template`; a link\ncan use a different template on each, or only one of the two.\n","example":"69df9e86d30eb3d7dff9d2ad"}}},"LinkStatsResponse":{"type":"object","properties":{"analytics":{"type":"object","properties":{"visits":{"type":"integer","description":"Number of human visits","example":1627},"bots":{"type":"integer","description":"Number of bot visits","example":25},"total":{"type":"integer","description":"Total number of visits (visits + bots)","example":1652},"countries":{"type":"integer","description":"Number of unique countries","example":69},"topCountries":{"type":"array","description":"Top countries by visit count","items":{"type":"object","properties":{"country":{"type":"string","description":"Country code (ISO 3166-1 alpha-2)","example":"FR"},"visits":{"type":"integer","description":"Number of visits from this country","example":937}}}},"topReferrers":{"type":"array","description":"Top referrers by visit count","items":{"type":"object","properties":{"referrer":{"type":"string","description":"Referrer URL or 'direct' for direct traffic","example":"https://l.instagram.com/"},"visits":{"type":"integer","description":"Number of visits from this referrer","example":967}}}},"button_clicks":{"type":"array","description":"Button clicks data (present when include_clicks=true).\nClick aggregates from ClickHouse table 'clicks_stats'. \nFiltered by link_id, project_id and date range (uses created_at).\nSorted by clicks desc, up to 1000 rows.\n","items":{"type":"object","properties":{"url":{"type":"string","description":"The URL that was clicked","example":"https://example.com/signup"},"btn_id":{"type":"string","nullable":true,"description":"Button identifier (null if not specified)","example":"abc123"},"clicks":{"type":"integer","description":"Number of clicks for this URL/button combination","example":5},"lastClick":{"type":"string","description":"Datetime of the last click","example":"2025-12-04 13:13:02.839"},"position":{"type":"integer","nullable":true,"description":"Position of the button (null if not specified)","example":0},"btn_v":{"type":"string","nullable":true,"description":"Button version identifier","example":"46"}}}}}},"link_id":{"type":"string","description":"The link identifier","example":"6889f4c2449bed3d380560451fdc"},"date_range":{"type":"object","properties":{"from":{"type":"string","format":"date-time","description":"Start date of the analytics period","example":"2025-10-26T00:00:00Z"},"to":{"type":"string","format":"date-time","description":"End date of the analytics period","example":"2025-10-27T00:00:00Z"},"timezone":{"type":"string","description":"Timezone for data aggregation (IANA tz string)","example":"UTC"},"from_utc":{"type":"string","format":"date-time","description":"The absolute start instant actually queried, after `timezone` was applied.\nEqual to `from` when the caller already pinned an offset (e.g. a trailing `Z`).\n","example":"2026-06-30T22:00:00.000Z"},"to_utc":{"type":"string","format":"date-time","description":"The absolute end instant actually queried, after `timezone` was applied.\nEqual to `to` when the caller already pinned an offset (e.g. a trailing `Z`).\n","example":"2026-07-31T22:00:00.000Z"}}},"project":{"type":"object","properties":{"project_id":{"type":"string","description":"The project identifier","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"project_name":{"type":"string","description":"The project name","example":"PROJECT NAME"}}}}},"ProjectStatsResponse":{"type":"object","properties":{"summary":{"type":"object","description":"High-level totals for the selected period","properties":{"totalViews":{"type":"integer","description":"Total views (non-bot) across the project","example":45230},"normalTraffic":{"type":"integer","description":"Non-bot traffic","example":42100},"botTraffic":{"type":"integer","description":"Bot traffic","example":3130},"totalCountries":{"type":"integer","description":"Number of unique countries","example":87},"totalReferrers":{"type":"integer","description":"Number of unique referrers","example":234}}},"dailyTraffic":{"type":"array","description":"Daily traffic breakdown for the selected period","items":{"type":"object","properties":{"date":{"type":"string","format":"date-time","example":"2024-01-01T00:00:00.000Z"},"totalViews":{"type":"integer","example":1250},"normalTraffic":{"type":"integer","example":1180},"botTraffic":{"type":"integer","example":70}}}},"topReferrers":{"type":"array","description":"Top referrers by views","items":{"type":"object","properties":{"referrer":{"type":"string","example":"google.com"},"views":{"type":"integer","example":15000}}}},"socialTraffic":{"type":"array","description":"Traffic by social platforms","items":{"type":"object","properties":{"platform":{"type":"string","example":"Facebook"},"views":{"type":"integer","example":8500}}}},"trafficByCountries":{"type":"array","description":"Per-day traffic by country","items":{"type":"object","properties":{"date":{"type":"string","format":"date-time","example":"2024-01-01T00:00:00.000Z"},"countries":{"type":"array","items":{"type":"object","properties":{"country":{"type":"string","description":"Country code (ISO 3166-1 alpha-2)","example":"US"},"views":{"type":"integer","example":450}}}}}}},"trafficByUrls":{"type":"array","description":"Destination URLs traffic (present if traffic_data_type is urls or both).\nEach item is enriched with currentNote from MongoDB links collection.\nWhen `include_clicks=true`, each item includes a `button_clicks` array.\n","items":{"type":"object","properties":{"date":{"type":"string","format":"date-time","example":"2025-10-10T00:00:00.000Z"},"host":{"type":"string","example":"emilycutie.me"},"u":{"type":"string","description":"Username or identifier","example":"92"},"id":{"type":"string","example":"688fe9a45202a5dd8e25d639"},"project_id":{"type":"string","example":"dfc5abb5-d7e3-49a5-9535-36f43ea6d4d8"},"url":{"type":"string","example":"emilycutie.me/92"},"clicks":{"type":"integer","example":2949},"bots":{"type":"integer","example":25},"human_proxy":{"type":"integer","description":"Human traffic through proxy","example":6},"human_vpn":{"type":"integer","description":"Human traffic through VPN","example":0},"human_proxy_vpn":{"type":"integer","description":"Human traffic through proxy or VPN","example":229},"note":{"type":"string","description":"Note from ClickHouse","example":"ismaud75"},"currentNote":{"type":"string","nullable":true,"description":"Current note from MongoDB links collection","example":"Campaign Q4 2024"},"countries":{"type":"array","items":{"type":"object","properties":{"country":{"type":"string","description":"Country code (ISO 3166-1 alpha-2)","example":"US"},"visits":{"type":"integer","example":305}}}},"button_clicks":{"type":"array","description":"Button clicks data for this URL (present when include_clicks=true)","items":{"type":"object","properties":{"url":{"type":"string","description":"The URL that was clicked","example":"https://example.com/signup"},"btn_id":{"type":"string","nullable":true,"description":"Button identifier (null if not specified)","example":"abc123"},"clicks":{"type":"integer","description":"Number of clicks for this URL/button combination","example":5},"lastClick":{"type":"string","description":"Datetime of the last click","example":"2025-12-04 13:13:02.839"},"position":{"type":"integer","nullable":true,"description":"Position of the button (null if not specified)","example":0},"btn_v":{"type":"string","nullable":true,"description":"Button version identifier","example":"46"}}}}}}},"trafficByLinks":{"type":"array","description":"Per-link traffic (present if traffic_data_type is links or both).\nEach item is enriched with currentNote from MongoDB links collection.\n","items":{"type":"object","properties":{"link_id":{"type":"string","example":"abc123"},"link_name":{"type":"string","example":"Campaign A"},"link_url":{"type":"string","format":"uri","example":"https://linkdm.co/abc123"},"folder_id":{"type":"string","example":"folder1"},"views":{"type":"integer","example":1200},"clicks":{"type":"integer","example":350},"note":{"type":"string","nullable":true,"description":"Note from ClickHouse","example":"Previous note"},"currentNote":{"type":"string","nullable":true,"description":"Current note from MongoDB links collection","example":"Updated campaign note"}}}}}},"FoldersListResponse":{"type":"object","properties":{"project_id":{"type":"string","description":"Unique identifier of the project","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"project":{"type":"object","properties":{"project_id":{"type":"string","description":"Unique identifier of the project","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"project_name":{"type":"string","description":"Name of the project","example":"My Project"}}},"folders":{"type":"array","description":"List of folders in the project, sorted by creation date (newest first)","items":{"type":"object","properties":{"_id":{"type":"string","description":"Unique identifier of the folder","example":"folder_abc123"},"name":{"type":"string","description":"Name of the folder","example":"Marketing Campaign"},"project_id":{"type":"string","description":"Project ID the folder belongs to","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"links_count":{"type":"integer","description":"Number of links in this folder","example":15},"created_at":{"type":"string","format":"date-time","description":"Timestamp when the folder was created","example":"2025-10-19T10:30:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"Timestamp when the folder was last updated","example":"2025-10-19T15:45:00.000Z"}}}}},"required":["project_id","project","folders"]},"FoldersStatsResponse":{"type":"object","properties":{"project_id":{"type":"string","example":"project123"},"date_range":{"type":"object","properties":{"from":{"type":"string","format":"date-time","example":"2024-01-01T00:00:00Z"},"to":{"type":"string","format":"date-time","example":"2024-01-31T23:59:59Z"},"timezone":{"type":"string","example":"UTC"},"from_utc":{"type":"string","format":"date-time","description":"The absolute start instant actually queried, after `timezone` was applied.\nEqual to `from` when the caller already pinned an offset (e.g. a trailing `Z`).\n","example":"2026-06-30T22:00:00.000Z"},"to_utc":{"type":"string","format":"date-time","description":"The absolute end instant actually queried, after `timezone` was applied.\nEqual to `to` when the caller already pinned an offset (e.g. a trailing `Z`).\n","example":"2026-07-31T22:00:00.000Z"}}},"project":{"type":"object","properties":{"project_id":{"type":"string","example":"project123"},"project_name":{"type":"string","example":"My Project"}}},"stats":{"type":"object","properties":{"folders":{"type":"array","items":{"type":"object","properties":{"folder_id":{"type":"string","example":"folder1"},"folder_name":{"type":"string","example":"Marketing Links"},"totalClicks":{"type":"integer","example":450},"totalViews":{"type":"integer","example":1200},"uniqueVisitors":{"type":"integer","example":890},"links":{"type":"array","items":{"type":"object","properties":{"link_id":{"type":"string","example":"link1"},"link_name":{"type":"string","example":"Campaign A"},"clicks":{"type":"integer","example":200},"views":{"type":"integer","example":500}}}}}}},"summary":{"type":"object","properties":{"totalFolders":{"type":"integer","example":12},"totalLinks":{"type":"integer","example":127},"totalClicks":{"type":"integer","example":11700},"totalViews":{"type":"integer","example":45200}}}}}}},"FolderStatsResponse":{"type":"object","properties":{"folder_id":{"type":"string","example":"folder1"},"folder_name":{"type":"string","example":"Marketing Links"},"date_range":{"type":"object","properties":{"from":{"type":"string","format":"date-time","example":"2024-01-01T00:00:00Z"},"to":{"type":"string","format":"date-time","example":"2024-01-31T23:59:59Z"},"timezone":{"type":"string","example":"UTC"},"from_utc":{"type":"string","format":"date-time","description":"The absolute start instant actually queried, after `timezone` was applied.\nEqual to `from` when the caller already pinned an offset (e.g. a trailing `Z`).\n","example":"2026-06-30T22:00:00.000Z"},"to_utc":{"type":"string","format":"date-time","description":"The absolute end instant actually queried, after `timezone` was applied.\nEqual to `to` when the caller already pinned an offset (e.g. a trailing `Z`).\n","example":"2026-07-31T22:00:00.000Z"}}},"analytics":{"type":"object","properties":{"visits":{"type":"integer","example":1200},"bots":{"type":"integer","example":85},"total":{"type":"integer","example":1285},"countries":{"type":"integer","example":45},"topCountries":{"type":"array","items":{"type":"object","properties":{"country":{"type":"string","example":"US"},"visits":{"type":"integer","example":450}}}},"topReferrers":{"type":"array","items":{"type":"object","properties":{"referrer":{"type":"string","example":"google.com"},"visits":{"type":"integer","example":380}}}},"topLinks":{"type":"array","items":{"type":"object","properties":{"link_id":{"type":"string","example":"link1"},"link_name":{"type":"string","example":"Campaign A"},"link_url":{"type":"string","format":"uri","example":"https://linkdm.co/abc123"},"visits":{"type":"integer","example":500},"clicks":{"type":"integer","example":200}}}}}},"trafficByUrls":{"type":"array","description":"Destination URLs traffic (present if traffic_data_type is urls or both).\nEach item is enriched with currentNote from MongoDB links collection.\nWhen `include_clicks=true`, each item includes a `button_clicks` array.\n","items":{"type":"object","properties":{"date":{"type":"string","format":"date-time","example":"2025-10-10T00:00:00.000Z"},"host":{"type":"string","example":"emilycutie.me"},"u":{"type":"string","description":"Username or identifier","example":"92"},"id":{"type":"string","example":"688fe9a45202a5dd8e25d639"},"project_id":{"type":"string","example":"dfc5abb5-d7e3-49a5-9535-36f43ea6d4d8"},"url":{"type":"string","example":"emilycutie.me/92"},"clicks":{"type":"integer","example":2949},"bots":{"type":"integer","example":25},"human_proxy":{"type":"integer","description":"Human traffic through proxy","example":6},"human_vpn":{"type":"integer","description":"Human traffic through VPN","example":0},"human_proxy_vpn":{"type":"integer","description":"Human traffic through proxy or VPN","example":229},"note":{"type":"string","description":"Note from ClickHouse","example":"ismaud75"},"currentNote":{"type":"string","nullable":true,"description":"Current note from MongoDB links collection","example":"Campaign Q4 2024"},"countries":{"type":"array","items":{"type":"object","properties":{"country":{"type":"string","description":"Country code (ISO 3166-1 alpha-2)","example":"US"},"visits":{"type":"integer","example":305}}}},"button_clicks":{"type":"array","description":"Button clicks data for this URL (present when include_clicks=true)","items":{"type":"object","properties":{"url":{"type":"string","description":"The URL that was clicked","example":"https://example.com/signup"},"btn_id":{"type":"string","nullable":true,"description":"Button identifier (null if not specified)","example":"abc123"},"clicks":{"type":"integer","description":"Number of clicks for this URL/button combination","example":5},"lastClick":{"type":"string","description":"Datetime of the last click","example":"2025-12-04 13:13:02.839"},"position":{"type":"integer","nullable":true,"description":"Position of the button (null if not specified)","example":0},"btn_v":{"type":"string","nullable":true,"description":"Button version identifier","example":"46"}}}}}}},"trafficByLinks":{"type":"array","description":"Per-link traffic (present if traffic_data_type is links or both).\nEach item is enriched with currentNote from MongoDB links collection.\n","items":{"type":"object","properties":{"link_id":{"type":"string","example":"abc123"},"link_name":{"type":"string","example":"Campaign A"},"link_url":{"type":"string","format":"uri","example":"https://linkdm.co/abc123"},"folder_id":{"type":"string","example":"folder1"},"views":{"type":"integer","example":1200},"clicks":{"type":"integer","example":350},"note":{"type":"string","nullable":true,"description":"Note from ClickHouse","example":"Previous note"},"currentNote":{"type":"string","nullable":true,"description":"Current note from MongoDB links collection","example":"Updated campaign note"}}}}}},"CreateTemplateRequest":{"type":"object","properties":{"t_name":{"type":"string","description":"Template name (optional)","example":"My Custom Template"},"type":{"type":"string","enum":["l_p","d_l"],"description":"Link type - 'l_p' for landing page or 'd_l' for direct link","example":"l_p"},"url":{"type":"string","description":"Target URL to redirect to (required when type is 'd_l')","format":"uri","example":"https://example.com"},"n":{"type":"string","description":"Display name (optional, can be empty)","example":"John Doe"},"bio":{"type":"string","description":"Bio or description text (optional, can be empty)","example":"Professional developer and designer"},"links":{"type":"array","description":"Array of link objects for the landing page","items":{"type":"object","properties":{"title":{"type":"string","example":"Portfolio"},"url":{"type":"string","format":"uri","example":"https://johndoe.com"}}}},"pp":{"type":"object","properties":{"url":{"type":"string","description":"Profile picture URL (can be empty)","example":"https://example.com/profile.jpg"},"enabled":{"type":"boolean","description":"Whether to show profile picture","example":true}}},"cover":{"type":"object","properties":{"url":{"type":"string","description":"Cover image URL (can be empty)","example":"https://example.com/cover.jpg"},"enabled":{"type":"boolean","description":"Whether to show cover image","example":true},"height":{"type":"number","description":"Cover image height in pixels","example":200}}},"background":{"type":"object","properties":{"type":{"type":"string","enum":["color","url"],"description":"Background type","example":"color"},"color":{"type":"string","description":"Background color (when type is 'color')","example":"#ffffff"},"url":{"type":"string","description":"Background image URL (when type is 'url')","example":"https://example.com/bg.jpg"}}},"template":{"type":"string","description":"Template identifier","example":"default"},"s":{"type":"object","description":"Social media settings","additionalProperties":true},"shield":{"type":"boolean","description":"Enable shield protection","example":false},"enabled":{"type":"boolean","description":"Whether the template is active","example":true},"note":{"type":"string","description":"Internal note (can be empty)","example":""}}},"UpdateTemplateRequest":{"type":"object","description":"All fields are optional - only provided fields will be updated","properties":{"t_name":{"type":"string","description":"Template name","example":"Updated Template Name"},"type":{"type":"string","enum":["l_p","d_l"],"description":"Link type","example":"l_p"},"url":{"type":"string","description":"Target URL to redirect to","format":"uri","example":"https://example.com"},"n":{"type":"string","description":"Display name","example":"Updated Name"},"bio":{"type":"string","description":"Bio or description text","example":"Updated bio"},"links":{"type":"array","description":"Array of link objects","items":{"type":"object","properties":{"title":{"type":"string","example":"Portfolio"},"url":{"type":"string","format":"uri","example":"https://johndoe.com"}}}},"pp":{"type":"object","properties":{"url":{"type":"string","description":"Profile picture URL","example":"https://example.com/profile.jpg"},"enabled":{"type":"boolean","description":"Whether to show profile picture","example":true}}},"cover":{"type":"object","properties":{"url":{"type":"string","description":"Cover image URL","example":"https://example.com/cover.jpg"},"enabled":{"type":"boolean","description":"Whether to show cover image","example":true},"height":{"type":"number","description":"Cover image height in pixels","example":200}}},"background":{"type":"object","properties":{"type":{"type":"string","enum":["color","url"],"description":"Background type","example":"color"},"color":{"type":"string","description":"Background color","example":"#ffffff"},"url":{"type":"string","description":"Background image URL","example":"https://example.com/bg.jpg"}}},"template":{"type":"string","description":"Template identifier","example":"modern"},"shield":{"type":"boolean","description":"Enable shield protection","example":true},"enabled":{"type":"boolean","description":"Whether the template is active","example":true},"note":{"type":"string","description":"Internal note","example":"Updated via API"}}},"TemplateResponse":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Template created successfully"},"data":{"type":"object","properties":{"template_id":{"type":"string","description":"Unique identifier for the template","example":"507f1f77bcf86cd799439011"},"t_name":{"type":"string","description":"Template name","example":"My Custom Template"},"created_at":{"type":"string","format":"date-time","description":"When the template was created","example":"2024-01-15T10:30:00Z"}}}}},"TemplatesListResponse":{"type":"object","description":"Response of `GET /api/v1/templates`.\n\nNote the envelope: this endpoint returns the templates under `templates`\n(alongside the family listed and the owning `project`), not under a `data`\nkey.\n","properties":{"templates":{"type":"array","description":"The project's templates for the requested family, newest first.\n\nWithout `?summary=true` each entry is the **full template document**,\nwhich for a Page Builder v2 template embeds an entire page. The fields\nbelow are the ones every family has in common; the rest of the document\nis the design itself and varies per family and per `template_kind`.\n","items":{"type":"object","properties":{"_id":{"type":"string","description":"Unique identifier of the template — unique **within its family**.\nThis is the value you send as `cs_template`, `cs_1-step`,\n`cs_3dots_template` or `cs_3dots_click_template` on a link.\n","example":"507f1f77bcf86cd799439011"},"t_name":{"type":"string","description":"Template name","example":"My Custom Template"},"template_kind":{"type":"string","enum":["v1","v2"],"description":"Design engine behind this template — `v2` is Page Builder v2\n(its page lives under `landing_v2`), `v1` is the classic editor.\n","example":"v2"},"project_id":{"type":"string","description":"Project identifier","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"folder_id":{"type":"string","description":"Template folder this template belongs to, when filed in one.","example":"6650b2aa11cc22dd33ee44ff"},"created_at":{"type":"string","format":"date-time","description":"When the template was created","example":"2024-01-15T10:30:00Z"},"updated_at":{"type":"string","format":"date-time","description":"When the template was last modified","example":"2024-02-01T09:12:00Z"}}}},"kind":{"type":"string","enum":["landing","first_step","three_dots"],"description":"The family actually listed — echoed back so you can tell an explicit\n`?kind=` from the `landing` default.\n","example":"three_dots"},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"TemplateDetailsResponse":{"type":"object","description":"Response of `GET /api/v1/templates/{template_id}`.\n\nNote the envelope: this endpoint returns the template under `template`\n(alongside the family read and the owning `project`), not under a `data` key.\n","properties":{"template":{"type":"object","description":"The full template document. The fields below are common to every family;\neverything else is the design itself and differs between a `v1` classic\ntemplate, a `v2` Page Builder page (`landing_v2`), a first-step template\nand a 3-dots overlay template (`three_dots_v2`).\n","properties":{"_id":{"type":"string","description":"Unique identifier of the template — unique **within its family**.\n","example":"507f1f77bcf86cd799439011"},"t_name":{"type":"string","description":"Template name","example":"My Custom Template"},"template_kind":{"type":"string","enum":["v1","v2"],"description":"Design engine behind this template — `v2` is Page Builder v2, `v1` is\nthe classic editor.\n","example":"v2"},"project_id":{"type":"string","description":"Project identifier","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"n":{"type":"string","description":"Display name baked into the template","example":"John Doe"},"bio":{"type":"string","description":"Bio or description","example":"Professional developer"},"pp":{"type":"object","description":"Profile picture settings"},"cover":{"type":"object","description":"Cover image settings"},"background":{"type":"object","description":"Background settings"},"links":{"type":"array","description":"Array of links","items":{"type":"object"}},"landing_v2":{"type":"object","description":"The Page Builder v2 page, present on a `template_kind: \"v2\"` landing\ntemplate. Read and written through the **Landing Pages** endpoints.\n"},"user":{"type":"object","description":"Whitelisted \"created by\" display fields of the template's author —\n`_id`, `name`, `email`, `color`. Never the full user record.\n"},"created_at":{"type":"string","format":"date-time","description":"When the template was created","example":"2024-01-15T10:30:00Z"},"updated_at":{"type":"string","format":"date-time","description":"When the template was last modified","example":"2024-02-01T09:12:00Z"}}},"kind":{"type":"string","enum":["landing","first_step","three_dots"],"description":"The family actually read — echoed back so you can tell an explicit\n`?kind=` from the `landing` default.\n","example":"first_step"},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"TemplateUpdateResponse":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Template updated successfully"},"data":{"type":"object","properties":{"template_id":{"type":"string","description":"Unique identifier for the template","example":"507f1f77bcf86cd799439011"},"updated_at":{"type":"string","format":"date-time","description":"When the template was updated","example":"2024-01-15T10:30:00Z"}}}}},"TemplateDeleteResponse":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Template deleted successfully"},"data":{"type":"object","properties":{"template_id":{"type":"string","description":"Unique identifier for the deleted template","example":"507f1f77bcf86cd799439011"},"deleted_at":{"type":"string","format":"date-time","description":"When the template was deleted","example":"2024-01-15T10:30:00Z"}}}}},"Page":{"type":"object","description":"A Landing v2 page: a theme/meta envelope plus an ordered list of sections. You do not have to memorize the section shapes - GET /api/v1/landing-engine returns the full, always-current catalog of section types (layouts, content slots, style keys), and GET /api/v1/landing-engine/example returns a ready-to-PUT starter page. Authoring is additive: only the page skeleton (a sections array, up to 500 sections, one level of nesting) and a 2 MB size cap are enforced; unknown style/content keys flow through untouched.","properties":{"id":{"type":"string","description":"Stable page id.","example":"page_ab12cd"},"version":{"type":"integer","description":"Always 2 for Landing v2.","example":2},"theme":{"type":"object","properties":{"font_family":{"type":"string","description":"A Google Font key.","example":"inter"},"color_primary":{"type":"string","description":"Brand / accent color (hex).","example":"#2563eb"},"color_text":{"type":"string","description":"Default body text color (hex).","example":"#0f172a"},"color_bg":{"type":"string","description":"Page background color (hex).","example":"#ffffff"},"radius_scale":{"type":"string","enum":["sharp","soft","rounded"],"description":"Global corner-radius scale token.","example":"soft"}}},"meta":{"type":"object","properties":{"title":{"type":"string","example":"Ana"},"description":{"type":"string","example":"Creator links"},"og_image_uploadcare_uuid":{"type":"string","nullable":true,"description":"Uploadcare UUID for the Open Graph image."},"favicon_uploadcare_uuid":{"type":"string","nullable":true,"description":"Uploadcare UUID for the favicon."}}},"sections":{"type":"array","description":"Ordered top-level sections. Each section is { id, type, layout?, background?, style?, slots?, children? }. See GET /api/v1/landing-engine for every type's layouts, slots, and style keys.","items":{"type":"object","properties":{"id":{"type":"string","example":"sec_1"},"type":{"type":"string","description":"One of section_types[].type from the engine contract.","example":"hero"},"layout":{"type":"string","description":"One of that type's layouts.","example":"centered"},"background":{"type":"object","description":"Section background (kind: inherit | color | image | gradient ...)."},"style":{"type":"object","description":"Style keys for this section type."},"slots":{"type":"object","description":"Content slots for this section type; each slot is an array of items shaped by the contract's slot_value_shapes."},"children":{"type":"array","description":"Container types only, one level deep.","items":{"type":"object"}}}}}}},"ProjectRef":{"type":"object","description":"The project the API key belongs to (echoed on every Landing Pages response).","properties":{"project_id":{"type":"string","example":"7c78db83-6bdd-4bb4-8545-c7cfdfc1e480"},"project_name":{"type":"string","example":"My Project"}}},"LandingReadResponse":{"type":"object","properties":{"landing":{"type":"object","properties":{"version":{"type":"integer","example":2},"state_returned":{"type":"string","enum":["published","draft"],"example":"published"},"page":{"description":"The resolved page (draft or published). Null if the resource has no Landing v2 page yet.","allOf":[{"$ref":"#/components/schemas/Page"}]},"draft_updated_at":{"type":"string","format":"date-time","nullable":true,"example":"2026-07-20T10:00:00.000Z"},"published_at":{"type":"string","format":"date-time","nullable":true,"example":"2026-07-20T10:00:00.000Z"},"created_from_template":{"type":"object","nullable":true,"description":"Provenance stamp, present when the page was created from a template."},"active":{"type":"boolean","description":"Link only - the public serve gate.","example":true}}},"customization_model":{"type":"string","enum":["page","template_dynamic","none"],"description":"Which model the resource uses (Model A = page, Model B = template_dynamic).","example":"page"},"dynamic":{"type":"object","description":"Link only, present only in Model B (a shared template is attached).","properties":{"cs_template":{"type":"string","nullable":true},"dynamic_informations":{"type":"object","nullable":true},"dynamic_links":{"type":"array","items":{"type":"object"}}}},"preview_url":{"type":"string","nullable":true,"description":"Link only - the live rendered page. null if the link is not served yet; templates have no preview_url.","example":"https://your-domain.com/ana"},"engine":{"type":"object","properties":{"contract_url":{"type":"string","example":"/api/v1/landing-engine"},"version":{"type":"string","example":"1.0.0"}}},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"WriteLandingRequest":{"type":"object","description":"The page to write. Send the canonical { page } wrapper (a bare page object is also accepted). For PATCH, send only the top-level page keys you want to change: theme and meta are merged one level deep; sections, platform_groups, and version are replaced wholesale. Get a valid starter from GET /api/v1/landing-engine/example.","properties":{"page":{"$ref":"#/components/schemas/Page"}},"required":["page"]},"WriteLandingResponse":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"landing":{"type":"object","properties":{"version":{"type":"integer","example":2},"state_written":{"type":"string","example":"published"},"draft_updated_at":{"type":"string","format":"date-time","example":"2026-07-20T12:00:00.000Z"},"published_at":{"type":"string","format":"date-time","example":"2026-07-20T12:00:00.000Z"},"active":{"type":"boolean","description":"Link only. A link write is publish + activate in one shot.","example":true}}},"preview_url":{"type":"string","nullable":true,"description":"Link only - the live rendered page; null for templates.","example":"https://your-domain.com/ana"},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"LandingHistoryListResponse":{"type":"object","properties":{"versions":{"type":"array","description":"Snapshots, newest first (rolling window of the last 60).","items":{"type":"object","properties":{"id":{"type":"string","example":"6650c3aa11cc22dd33ee44ff"},"saved_at":{"type":"string","format":"date-time","example":"2026-07-20T12:00:00.000Z"},"source":{"type":"string","description":"What produced the snapshot.","example":"api"},"page":{"description":"The full page for this version. Omitted when ?include_pages=false.","allOf":[{"$ref":"#/components/schemas/Page"}]}}}},"count":{"type":"integer","example":3},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"LandingHistoryVersionResponse":{"type":"object","properties":{"version":{"type":"object","properties":{"id":{"type":"string","example":"6650c3aa11cc22dd33ee44ff"},"saved_at":{"type":"string","format":"date-time","example":"2026-07-20T12:00:00.000Z"},"source":{"type":"string","example":"api"},"page":{"description":"The full, un-folded page for this version.","allOf":[{"$ref":"#/components/schemas/Page"}]}}},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"DynamicOverridesRequest":{"type":"object","description":"Model B per-link overrides. PUT replaces the whole triplet - any field you omit is cleared (PUT {} resets the link to no overrides and detaches the template). PATCH merges - only the keys you send change; dynamic_informations is merged one level deep. All three fields are optional and nullable; send cs_template: null to detach. Unknown keys are rejected.","properties":{"cs_template":{"type":"string","nullable":true,"description":"The shared template's ObjectId (attaches the design). null detaches.","example":"6650b2aa11cc22dd33ee44ff"},"dynamic_informations":{"type":"object","nullable":true,"description":"Overrides the first hero section (name / photo).","properties":{"enabled":{"type":"boolean"},"pp_enabled":{"type":"boolean"},"n":{"type":"string","description":"Display name.","example":"Ana"},"pp":{"type":"object","description":"Profile photo.","properties":{"url":{"type":"string","example":"https://ucarecdn.com/example-uuid/"},"enabled":{"type":"boolean"},"hide_section":{"type":"boolean"},"size":{"type":"number","minimum":60,"maximum":280},"border":{"type":"object","description":"Optional avatar border (color, style, width, sides, inner)."}}}}},"dynamic_links":{"type":"array","nullable":true,"description":"Replaces the first links_list section's items (content only; the template design is untouched).","items":{"type":"object","properties":{"title":{"type":"string","example":"My shop"},"url":{"type":"string","format":"uri","example":"https://shop.example/ana"}}}}}},"DynamicOverridesResponse":{"type":"object","properties":{"ok":{"type":"boolean","description":"Present on PUT / PATCH writes; omitted on GET.","example":true},"dynamic_overrides":{"type":"object","properties":{"cs_template":{"type":"string","nullable":true,"example":"6650b2aa11cc22dd33ee44ff"},"dynamic_informations":{"type":"object","nullable":true},"dynamic_links":{"type":"array","items":{"type":"object"}}}},"customization_model":{"type":"string","enum":["page","template_dynamic","none"],"example":"template_dynamic"},"preview_url":{"type":"string","nullable":true,"example":"https://your-domain.com/ana"},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"EngineContractResponse":{"type":"object","description":"The render contract (\"the engine\"): a machine-readable description of the Page JSON we render, generated from the same section registry the editor uses, so it never drifts from what actually renders.","properties":{"engine":{"type":"object","properties":{"contract_version":{"type":"string","example":"1.0.0"},"generated_from":{"type":"string","example":"live-registry"},"notation":{"type":"string","description":"How to read the field lines in each section type."},"page_schema":{"type":"object","description":"The annotated page/theme/meta envelope schema."},"section_schema":{"type":"object","description":"The annotated section-object schema."},"slot_value_shapes":{"type":"object","description":"The JSON shape of each slot item kind (text, button, link_card, ...)."},"section_types":{"type":"array","description":"The full catalog of section types (filtered to one when ?section= is set).","items":{"type":"object","properties":{"type":{"type":"string","example":"hero"},"label":{"type":"string","example":"Hero"},"category":{"type":"string","example":"header"},"layouts":{"type":"array","items":{"type":"string"},"example":["full_bleed","centered","split","profile_card"]},"slots":{"type":"array","items":{"type":"object"}},"style_groups":{"type":"array","items":{"type":"object"}},"accepts_children":{"type":"boolean"},"allowed_child_types":{"type":"array","items":{"type":"string"}}}}}}},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"EngineExampleResponse":{"type":"object","properties":{"example":{"type":"object","properties":{"contract_version":{"type":"string","example":"1.0.0"},"page":{"description":"A ready-to-PUT example page (hero + links_list).","allOf":[{"$ref":"#/components/schemas/Page"}]},"usage":{"type":"string","example":"PUT this `page` to /api/v1/links/{link_id}/landing or /api/v1/templates/{template_id}/landing to publish it."}}},"project":{"$ref":"#/components/schemas/ProjectRef"}}},"LandingError":{"type":"object","description":"Error returned by the Landing Pages endpoints. (Auth 401 and rate-limit 429 errors use the standard API error envelope instead - see the Error schema.)","properties":{"error":{"type":"string","description":"Short error code / summary.","example":"Invalid link_id"},"message":{"type":"string","description":"Human-readable detail (present on most errors).","example":"This resource has no Landing v2 page yet. Use PUT to create one, then PATCH to amend it."},"details":{"type":"array","description":"Present on validation failures.","items":{"type":"object","properties":{"field":{"type":"string","example":"cs_template"},"message":{"type":"string","example":"cs_template must be a valid MongoDB ObjectId"}}}}}},"ShieldConfig":{"type":"object","description":"A link's complete Shield configuration, as returned by every Shield endpoint.","properties":{"enabled":{"type":"boolean","description":"Master switch. Shield never runs on a link where this is false.","example":true},"preset":{"type":"string","description":"The preset last applied (`instagram`, `bots_only`, `hard_404`), or null. Cosmetic on read — the rules below are what actually decide. Editing a bucket or the block screen without naming a preset clears it.","example":"instagram"},"model":{"type":"string","enum":["v2","legacy"],"description":"`v2` — the ordered rule engine decides. `legacy` — this link still runs on the pre-V2 tri-state fields, and `rules` below is the deterministic compilation of them (exactly what the dashboard opens). Writing any rule, bucket or preset migrates the link to `v2`.","example":"v2"},"mode":{"type":"string","enum":["simple","advanced"],"description":"Which dashboard view this config opens in: `advanced` as soon as a rule is not representable as one of the simple buckets.","example":"simple"},"rules":{"type":"array","description":"The ordered rule list. First enabled match wins.","items":{"$ref":"#/components/schemas/ShieldRule"}},"buckets":{"type":"object","description":"The same configuration expressed as the simple traffic buckets, for the buckets that are currently set (an `off` bucket is omitted). Keys are bucket names (`bot_known`, `bot_unknown`, `net_proxy`, `net_vpn`, `net_datacenter`) or `bot:<botId>` for a pinned crawler.","additionalProperties":{"type":"object","properties":{"action":{"type":"string","enum":["off","block","allow","redirect"]},"redirect_url":{"type":"string"},"block":{"$ref":"#/components/schemas/ShieldBlockScreen"},"deeplinks_logic":{"type":"object"}}},"example":{"bot_known":{"action":"block"},"net_vpn":{"action":"block"}}},"block":{"$ref":"#/components/schemas/ShieldBlockScreen"},"ready":{"type":"boolean","description":"False when Shield is on but a decoy landing in use has no page attached — those visitors would silently fall back to a 404. Writes that would leave the link in this state are rejected with 400.","example":true},"issues":{"type":"array","description":"One entry per unconfigured decoy (empty when `ready` is true).","items":{"type":"object","properties":{"scope":{"type":"string","description":"`default` for the link block screen, otherwise the offending rule id.","example":"default"},"message":{"type":"string"}}}},"stats":{"type":"object","readOnly":true,"properties":{"rules_total":{"type":"integer","example":5},"rules_enabled":{"type":"integer","example":5},"custom_rules":{"type":"integer","description":"Rules that are not one of the simple buckets.","example":0},"blocks":{"type":"integer","example":5},"allows":{"type":"integer","example":0},"redirects":{"type":"integer","example":0}}},"legacy":{"type":"object","readOnly":true,"description":"The pre-V2 tri-state fields, exposed for transparency. They only decide anything while `model` is `legacy`.","properties":{"vpn":{"type":"string","enum":["off","block","allow"]},"proxy":{"type":"string","enum":["off","block","allow"]},"bot_known":{"type":"string","enum":["off","block","allow"]},"bot_unknown":{"type":"string","enum":["off","block","allow"]},"bot_rules":{"type":"object","additionalProperties":{"type":"string"}},"block_vpn":{"type":"boolean"},"block_proxy":{"type":"boolean"}}}}},"ShieldRule":{"type":"object","description":"One Shield rule: WHEN (a nestable all/any condition tree) -> THEN (block / allow / redirect). Rules are evaluated top-to-bottom and the FIRST enabled rule whose condition matches wins; traffic matching no rule is served the real page.","required":["action"],"properties":{"id":{"type":"string","description":"Stable identifier. Keep the id you read back to update a rule in place; a rule sent without one is assigned `rule_<n>`.","example":"simple_bot_known"},"enabled":{"type":"boolean","description":"Disabled rules are skipped at serve time. Defaults to true.","default":true,"example":true},"label":{"type":"string","description":"Free-text label shown in the dashboard. Purely cosmetic.","example":"Link scanners"},"match":{"$ref":"#/components/schemas/ShieldConditionGroup"},"action":{"type":"object","description":"What happens to a visitor this rule matches.","required":["type"],"properties":{"type":{"type":"string","enum":["block","allow","redirect"],"description":"`block` conceals the real page behind this rule's block screen (or the link default when the rule sets none). `allow` whitelists the visitor — the real destination is served and no later rule is evaluated. `redirect` sends the visitor to `redirect_url`.","example":"block"},"redirect_url":{"type":"string","format":"uri","description":"Required when `type` is `redirect`. A redirect rule with no URL is skipped at serve time rather than trapping the visitor.","example":"https://example.com/sorry"},"block":{"$ref":"#/components/schemas/ShieldBlockScreen"},"deeplinks_logic":{"type":"object","description":"Per-rule deeplink override, applied only when the visitor reaches an interactive page: an `allow` rule (the real destination) or a `block` rule whose screen is a landing decoy. Keys are the platform-scoped deeplinks_logic keys, each value a `{ \"type\": \"...\" }` behavior (`open_in_webview` / `without_deeplink` kill the deeplink, `3_dots_extra_user_steps` forces the overlay).","additionalProperties":{"type":"object","properties":{"type":{"type":"string","example":"open_in_webview"}}},"example":{"instagram_landing":{"type":"open_in_webview"},"direct_browser_open":{"type":"without_deeplink"}}}}},"summary":{"type":"string","readOnly":true,"description":"Plain-English rendering of `match`, generated on read.","example":"registered bots or VPN"},"warning":{"type":"object","readOnly":true,"description":"Present when the rule can never fire as written (no condition yet, or a redirect with no URL).","properties":{"kind":{"type":"string","enum":["empty","redirect_missing_url"]},"message":{"type":"string","example":"No condition yet — this rule never matches."}}}}},"ShieldConditionGroup":{"type":"object","description":"A condition tree. A GROUP combines children with `all` (every child must match) or `any` (at least one). A child is either another group or a LEAF (`{ type, match, values }`). Nesting is allowed up to 5 levels. An empty tree never matches, so a half-built block rule can never blackhole your traffic.\n\nLeaf `type` values and the values each one accepts are published by `GET /api/v1/shield`: BOT, NETWORK, COUNTRY, DEVICE, PLATFORM, OS, BROWSER, BROWSER_LANGUAGE, URL_PARAM, USER_AGENT.","properties":{"op":{"type":"string","enum":["all","any"],"default":"any","description":"How the children of this group are combined.","example":"any"},"rules":{"type":"array","description":"Child nodes — nested groups and/or condition leaves.","items":{"type":"object","description":"Either a nested group (`{ op, rules }`) or a leaf (`{ type, match, values }`).","properties":{"op":{"type":"string","enum":["all","any"],"description":"Present on a nested GROUP."},"rules":{"type":"array","description":"Present on a nested GROUP.","items":{"type":"object"}},"type":{"type":"string","description":"Present on a LEAF — the signal being tested.","enum":["BOT","NETWORK","COUNTRY","DEVICE","PLATFORM","OS","BROWSER","BROWSER_LANGUAGE","URL_PARAM","USER_AGENT"],"example":"BOT"},"match":{"type":"string","enum":["is","is_not"],"default":"is","description":"Present on a LEAF — whether the leaf is negated.","example":"is"},"values":{"type":"array","description":"Present on a LEAF — the accepted values, OR-ed together. Must be non-empty. BOT accepts `any` / `known` / `unknown` or a registered bot id; NETWORK accepts `proxy` / `vpn` / `datacenter`; COUNTRY accepts ISO 3166-1 alpha-2 codes; URL_PARAM and USER_AGENT accept free text.","items":{"type":"string"},"example":["known"]}}}}},"example":{"op":"any","rules":[{"type":"BOT","match":"is","values":["known"]},{"type":"NETWORK","match":"is","values":["vpn","datacenter"]}]}},"ShieldBlockScreen":{"type":"object","description":"What a blocked visitor is served. Used both as the link's DEFAULT screen (`shield.block`, which also accepts `do_nothing`) and as a per-rule override (`rule.action.block`, where `do_nothing` is not allowed — a per-rule \"let through\" is the `allow` action). A rule with no screen of its own inherits the link default.","properties":{"behavior":{"type":"string","enum":["not_found","landing","three_dots","do_nothing"],"default":"not_found","description":"`not_found` serves a plain 404. `landing` serves a decoy landing page. `three_dots` serves the link's \"open in browser\" 3-dots overlay. `do_nothing` lets the visitor through (link default only).","example":"landing"},"landing":{"type":"object","description":"The decoy page, required when `behavior` is `landing`. Send the smallest reference you have — the API resolves the rest (template name, page snapshot, link slug + domain) server-side and returns the resolved object.","properties":{"source":{"type":"string","enum":["template","link","self"],"default":"template","description":"`template` — one of your templates, resolved LIVE at serve time (edit the template and blocked visitors see the new version). `link` — another link's live landing page. `self` — shorthand for this very link's own landing (write-only alias; it is stored and read back as `link`).","example":"template"},"template_id":{"type":"string","description":"Template to serve. Send this alone; name and snapshot are filled in for you.","example":"692d91eec003c7d6b3ab6273"},"template_name":{"type":"string","readOnly":true,"description":"Resolved template name.","example":"Clean bio page"},"template_generated":{"type":"boolean","description":"True when the template was produced by the dashboard's \"generate optimized decoy\" action."},"link_id":{"type":"string","description":"Link whose live landing is served. Alternative to link_u + link_domain.","example":"6650a1bb22cc33dd44ee55ff"},"link_u":{"type":"string","description":"Slug of the link whose landing is served (paired with link_domain).","example":"john"},"link_domain":{"type":"string","description":"Domain of the link whose landing is served (paired with link_u).","example":"lnkdm.me"},"link_name":{"type":"string","readOnly":true,"description":"Resolved display name of the referenced link.","example":"John's page"},"has_page_snapshot":{"type":"boolean","readOnly":true,"description":"True when a page snapshot is stored as the serve-time fallback. The snapshot itself is omitted from responses unless you pass `?include_page=true`.","example":true},"page":{"type":"object","description":"Landing v2 page snapshot. Returned only with `?include_page=true`. You may also POST a self-contained page here instead of a template_id."}}}}},"ShieldWriteRequest":{"type":"object","description":"Shield write body. Send any combination of the fields below; they are applied in a fixed order so a body always means the same thing:\n\n1. `preset` — the base: rewrites every simple bucket and the default block screen.\n2. `rules` — replaces the whole rule array (the advanced escape hatch).\n3. `buckets` — per-bucket tweaks layered on top.\n4. `block` — the default screen every rule without its own inherits.\n\nPUT starts from an EMPTY config, so anything you omit is reset. PATCH starts from what is stored, so anything you omit is left alone (and `block` is merged one level deep instead of replaced).","properties":{"enabled":{"type":"boolean","description":"Master switch. Shield never runs on a link where this is false.","example":true},"preset":{"type":"string","enum":["instagram","bots_only","hard_404"],"description":"Apply a one-click protection profile — it generates the rules and the block screen for you. Send null to clear the recorded preset. See GET /api/v1/shield/presets.","example":"instagram"},"rules":{"type":"array","description":"The full ordered rule list (max 200). Replaces whatever is stored.","items":{"$ref":"#/components/schemas/ShieldRule"}},"buckets":{"type":"object","description":"Simple mode: set common traffic buckets without writing rules. Each bucket is backed by ONE real rule, so anything you set here also shows up in `rules`. Keys: `bot_known`, `bot_unknown`, `net_proxy`, `net_vpn`, `net_datacenter`, or `bot:<botId>` to pin one crawler. Values are either the action string (`off` / `block` / `allow`) or an object.","additionalProperties":{"oneOf":[{"type":"string","enum":["off","block","allow"]},{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["off","block","allow","redirect"]},"redirect_url":{"type":"string","format":"uri","description":"Required when action is `redirect`."},"block":{"$ref":"#/components/schemas/ShieldBlockScreen"},"deeplinks_logic":{"type":"object","description":"Per-bucket deeplink override (allow buckets and landing-decoy block buckets only)."}}}]},"example":{"bot_known":"block","net_vpn":{"action":"redirect","redirect_url":"https://example.com/vpn"}}},"block":{"$ref":"#/components/schemas/ShieldBlockScreen"}}},"ShieldResponse":{"type":"object","description":"Response envelope of every per-link Shield endpoint.","properties":{"ok":{"type":"boolean","description":"Present on writes.","example":true},"shield":{"$ref":"#/components/schemas/ShieldConfig"},"updated_fields":{"type":"array","description":"Present on writes — the link fields the call touched.","items":{"type":"string"},"example":["shield","shield_rules","shield_block","shield_preset"]},"contract":{"type":"object","description":"Where to find the machine-readable Shield vocabulary.","properties":{"url":{"type":"string","example":"/api/v1/shield"},"version":{"type":"string","example":"2.0.0"}}},"link":{"type":"object","properties":{"link_id":{"type":"string","example":"6650a1bb22cc33dd44ee55ff"},"u":{"type":"string","example":"john"},"domain":{"type":"string","example":"lnkdm.me"}}},"project":{"$ref":"#/components/schemas/ProjectRef"},"message":{"type":"string","description":"Present on writes.","example":"Shield configuration saved"}}},"ShieldContractResponse":{"type":"object","description":"The machine-readable Shield vocabulary — condition types and their accepted values, actions, block screens, simple buckets, presets and the registered-bot registry. Generated from the same catalogues the dashboard renders, so it can never describe a Shield the product does not have. Build your integration against this instead of hard-coding the enums.","properties":{"version":{"type":"string","example":"2.0.0"},"model":{"type":"object","properties":{"summary":{"type":"string","example":"An ordered list of rules, each WHEN -> THEN. First enabled match wins."},"evaluation":{"type":"string","example":"first_match_wins"},"limits":{"type":"object","properties":{"max_rules":{"type":"integer","example":200},"max_condition_depth":{"type":"integer","example":5},"max_values_per_condition":{"type":"integer","example":200}}},"notes":{"type":"array","items":{"type":"string"}}}},"actions":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","example":"block"},"description":{"type":"string"}}}},"block_screens":{"type":"object","properties":{"link_default":{"type":"array","items":{"type":"object","properties":{"behavior":{"type":"string"},"description":{"type":"string"}}}},"per_rule":{"type":"array","items":{"type":"object","properties":{"behavior":{"type":"string"},"description":{"type":"string"}}}},"decoy_sources":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string"},"description":{"type":"string"}}}}}},"condition_types":{"type":"array","description":"Every leaf `type` a rule can use, with the values it accepts.","items":{"type":"object","properties":{"type":{"type":"string","example":"NETWORK"},"description":{"type":"string"},"value_kind":{"type":"string","enum":["enum","free_text","country","bot"]},"operators":{"type":"array","items":{"type":"string"},"example":["is","is_not"]},"values":{"type":"array","description":"Present for enum-valued types.","items":{"type":"string"},"example":["proxy","vpn","datacenter"]},"value_labels":{"type":"object","description":"Human labels for the values above.","additionalProperties":{"type":"string"}}}}},"buckets":{"$ref":"#/components/schemas/ShieldBucketCatalogue"},"presets":{"type":"array","items":{"$ref":"#/components/schemas/ShieldPreset"}},"bots":{"$ref":"#/components/schemas/ShieldBotRegistry"}}},"ShieldPreset":{"type":"object","description":"A one-click protection profile. Applying a preset writes real rules into the link's `rules` array (and sets its default block screen), so everything it did stays visible and tweakable afterwards.","properties":{"id":{"type":"string","description":"Value to send as `preset` on a Shield write.","example":"instagram"},"name":{"type":"string","example":"Instagram Optimized"},"description":{"type":"string","example":"Bots and anonymized traffic see your landing — your real page stays hidden from link scanners."},"block_screen":{"type":"string","enum":["landing","three_dots","not_found"],"description":"The default block screen this preset configures.","example":"landing"},"default_decoy_source":{"type":"string","enum":["template","link"],"description":"For a `landing` preset, where the decoy comes from when the link has none yet. `link` means the link's OWN landing is served to blocked visitors, so the preset is serveable in one call without picking a template.","example":"link"},"buckets":{"type":"object","description":"The traffic buckets this preset sets, and to what. Buckets not listed are turned off.","additionalProperties":{"type":"string","enum":["off","block","allow","redirect"]},"example":{"bot_known":"block","bot_unknown":"block","net_proxy":"block","net_vpn":"block","net_datacenter":"block"}},"cuts_deeplinks":{"type":"boolean","description":"True when the preset also disables every deeplink on the traffic it blocks — a link scanner that followed a deeplink would expose the real destination behind the decoy.","example":true}}},"ShieldBucketCatalogue":{"type":"object","description":"Simple mode: the common traffic buckets. Each bucket is backed by ONE real rule, so anything you set through `buckets` also shows up in `rules`.","properties":{"description":{"type":"string"},"actions":{"type":"array","items":{"type":"string"},"example":["off","block","allow","redirect"]},"items":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","description":"The name to use as a `buckets` key.","example":"net_vpn"},"group":{"type":"string","enum":["bots","network"],"example":"network"},"condition":{"type":"object","description":"The rule this bucket writes.","properties":{"type":{"type":"string","example":"NETWORK"},"values":{"type":"array","items":{"type":"string"},"example":["vpn"]}}},"description":{"type":"string","example":"Traffic coming through a VPN service."}}}}}},"ShieldBotRegistry":{"type":"object","description":"The crawlers LinkScale recognizes by name. Use an id as a BOT condition value (or as a `bot:<id>` bucket) to pin that one crawler to its own outcome. Anything automated that is NOT in this list falls into the `unknown` bucket.","properties":{"description":{"type":"string"},"version":{"type":"string","example":"2.0.0"},"kinds":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["search","social","ai","tool"],"example":"social"},"label":{"type":"string","example":"Social previews"}}}},"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Value to use in a BOT condition or a `bot:<id>` bucket.","example":"facebook"},"name":{"type":"string","example":"Facebook / Instagram / Threads"},"kind":{"type":"string","enum":["search","social","ai","tool"],"example":"social"}}}}}},"ShieldError":{"type":"object","description":"Error returned by the Shield endpoints. (Auth 401 and rate-limit 429 errors use the standard API error envelope instead - see the Error schema.)","properties":{"error":{"type":"string","description":"Short error code / summary.","example":"Validation failed"},"message":{"type":"string","description":"Human-readable detail (present on most errors).","example":"block.behavior is \"landing\" but no decoy page is attached."},"status":{"type":"integer","example":400},"details":{"type":"array","description":"Present on validation failures - one entry per problem, with the exact path inside the body you sent.","items":{"type":"object","properties":{"field":{"type":"string","example":"rules[0].match.rules[0].values[0]"},"message":{"type":"string","example":"\"tor\" is not a valid NETWORK value. Allowed: proxy, vpn, datacenter"}}}},"timestamp":{"type":"string","format":"date-time"}}},"TrendingLinksResponse":{"type":"object","required":["success","data"],"properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","required":["summary","links","query_time_ms"],"properties":{"summary":{"type":"object","description":"Aggregated metrics across all links for the current and previous windows.","properties":{"total_current_visits":{"type":"integer","description":"Total visits across all links in the current window","example":4520},"total_previous_visits":{"type":"integer","description":"Total visits across all links in the previous window","example":3100},"total_change":{"type":"integer","description":"Absolute difference (current - previous)","example":1420},"total_change_percent":{"type":"number","format":"float","description":"Percentage change between windows","example":45.8},"total_links":{"type":"integer","description":"Number of links with activity in either window","example":87},"trend_counts":{"type":"object","description":"Count of links per trend category","properties":{"spike":{"type":"integer","description":"Links with >= 200% growth AND >= 20 current visits","example":3},"rising":{"type":"integer","description":"Links with >= 50% growth","example":12},"stable":{"type":"integer","description":"Links with change between -30% and +50%","example":45},"declining":{"type":"integer","description":"Links with change between -60% and -30%","example":18},"dropping":{"type":"integer","description":"Links with change < -60%","example":5},"new":{"type":"integer","description":"Links with 0 previous visits AND > 0 current visits","example":4}}}}},"links":{"type":"array","description":"Individual link trends, sorted by spike_score descending.","items":{"type":"object","properties":{"id":{"type":"string","description":"Link ID","example":"link_abc123"},"u":{"type":"string","description":"Original destination URL","example":"https://example.com/landing-page"},"host":{"type":"string","description":"Domain of the destination URL","example":"example.com"},"t":{"type":"string","description":"Short URL slug","example":"xY7kq"},"deleted":{"type":"boolean","description":"Whether the link has been deleted","example":false},"current_visits":{"type":"integer","description":"Total visits in the current window","example":320},"previous_visits":{"type":"integer","description":"Total visits in the previous window","example":45},"current_normal":{"type":"integer","description":"Human visitors (no proxy, no VPN) in current window","example":280},"current_proxy":{"type":"integer","description":"Human visitors via proxy only in current window","example":15},"current_vpn":{"type":"integer","description":"Human visitors via VPN only in current window","example":20},"current_proxy_vpn":{"type":"integer","description":"Human visitors via both proxy and VPN in current window","example":2},"current_bots":{"type":"integer","description":"Bot visits in current window","example":3},"current_spam":{"type":"integer","description":"Spam visits in current window","example":0},"previous_normal":{"type":"integer","description":"Human visitors (no proxy, no VPN) in previous window","example":40},"previous_proxy":{"type":"integer","description":"Human visitors via proxy only in previous window","example":2},"previous_vpn":{"type":"integer","description":"Human visitors via VPN only in previous window","example":3},"previous_proxy_vpn":{"type":"integer","description":"Human visitors via both proxy and VPN in previous window","example":0},"current_unique_ips":{"type":"integer","description":"Unique IPs in the current window","example":290},"previous_unique_ips":{"type":"integer","description":"Unique IPs in the previous window","example":42},"absolute_change":{"type":"integer","description":"current_visits - previous_visits","example":275},"percent_change":{"type":"number","format":"float","description":"Percentage change between windows","example":611.1},"spike_score":{"type":"number","format":"float","description":"Composite ranking score balancing absolute growth, relative growth, and volume.\n\n`spike_score = (sqrt(absoluteChange) * 0.4) + (min(percentChange/100, 10) * 0.4) + (log2(currentVisits) * 0.2)`\n","example":14.82},"trend":{"type":"string","enum":["spike","rising","stable","declining","dropping","new"],"description":"Trend classification based on percent change:\n- `new`: previous == 0 AND current > 0\n- `spike`: percentChange >= 200% AND current >= 20\n- `rising`: percentChange >= 50%\n- `stable`: percentChange >= -30%\n- `declining`: percentChange >= -60%\n- `dropping`: percentChange < -60%\n","example":"spike"},"countries":{"type":"array","description":"Per-country breakdown of visits","items":{"type":"object","properties":{"country":{"type":"string","description":"ISO 3166-1 alpha-2 country code","example":"US"},"visits":{"type":"integer","description":"Total visits from this country","example":150},"proxy":{"type":"integer","description":"Proxy visits from this country","example":5},"vpn":{"type":"integer","description":"VPN visits from this country","example":12},"proxy_vpn":{"type":"integer","description":"Proxy + VPN visits from this country","example":1}}}}}}},"query_time_ms":{"type":"integer","description":"Server-side query execution time in milliseconds","example":142}}}}},"GenerateSignatureRequest":{"type":"object","description":"Request body for generating a secure upload signature. All fields are optional.\n\n**Signature Expiration:**\n- Signatures are time-limited for security\n- Must be between 1-60 minutes\n- Default: 10 minutes\n- Recommended: 10-15 minutes for most use cases\n\n**MIME Type Restrictions:**\n- Optional: Restrict allowed file types\n- Format: Array of MIME type strings\n- Examples: [\"image/png\", \"image/jpeg\"], [\"video/mp4\"]\n- If not specified: All file types allowed\n\n**File Size Limits:**\n- Optional: Set maximum file size in bytes\n- Format: Integer (bytes)\n- Examples: 5242880 (5MB), 10485760 (10MB), 52428800 (50MB)\n- If not specified: No size limit enforced\n\n**Common File Size Values:**\n- 1 MB = 1,048,576 bytes\n- 5 MB = 5,242,880 bytes\n- 10 MB = 10,485,760 bytes\n- 50 MB = 52,428,800 bytes\n- 100 MB = 104,857,600 bytes\n","properties":{"expiration_minutes":{"type":"integer","minimum":1,"maximum":60,"default":10,"description":"Signature expiration time in minutes (1-60).\nAfter this time, the signature becomes invalid and cannot be used for uploads.\nRecommended: 10 minutes for standard uploads, 30 minutes for large files.\n","example":10},"allowed_mime_types":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Optional array of allowed MIME types to restrict uploads.\nIf specified, only files matching these MIME types can be uploaded.\nIf null/omitted, all file types are allowed.\n\nCommon MIME types:\n- Images: image/png, image/jpeg, image/gif, image/webp, image/svg+xml\n- Videos: video/mp4, video/webm, video/mov\n- Audio: audio/mpeg, audio/wav, audio/ogg\n- Documents: application/pdf, application/json, text/plain\n","example":["image/png","image/jpeg","image/webp"]},"max_file_size":{"type":"integer","nullable":true,"description":"Optional maximum file size in bytes.\nIf specified, files larger than this size will be rejected.\nIf null/omitted, no size limit is enforced.\n\nRecommended limits:\n- Profile pictures: 5 MB (5242880 bytes)\n- Images: 10 MB (10485760 bytes)\n- Videos: 50-100 MB (52428800-104857600 bytes)\n- Documents: 10 MB (10485760 bytes)\n","example":10485760}}},"UploadSignatureResponse":{"type":"object","description":"Response containing all necessary configuration to upload a file to Uploadcare.\nUse these values to construct your FormData for the upload request.\n\n**Important Fields:**\n- `upload_config`: Contains all upload parameters\n- `project`: Information about your project\n\n**How to Use:**\n1. Extract `upload_config` from response\n2. Create FormData with all required fields\n3. POST FormData to `upload_config.upload_url`\n4. Receive `file_id` from Uploadcare\n5. Poll GET /api/v1/assets/{file_id} for validation\n","properties":{"upload_config":{"type":"object","description":"Complete upload configuration for Uploadcare","properties":{"public_key":{"type":"string","description":"Uploadcare public key for your project.\nRequired FormData field: UPLOADCARE_PUB_KEY\n","example":"your_uploadcare_public_key"},"expire":{"type":"integer","description":"Unix timestamp (seconds) when signature expires.\nRequired FormData field: expire\nMust be converted to string when appending to FormData.\n","example":1728481200},"signature":{"type":"string","description":"Secure signature for authenticated upload.\nRequired FormData field: signature\nGenerated server-side using secret key and expiration time.\n","example":"abc123def456789..."},"upload_url":{"type":"string","format":"uri","description":"Uploadcare upload endpoint URL.\nPOST your FormData to this URL.\nStandard URL: https://upload.uploadcare.com/base/\n","example":"https://upload.uploadcare.com/base/"},"metadata":{"type":"object","description":"Metadata to attach to the upload for tracking and validation.\nThese fields are required for webhook validation.\n","properties":{"project_id":{"type":"string","description":"Your project identifier.\nRequired FormData field: metadata[project_id]\n","example":"proj_abc123"},"api_key_id":{"type":"string","description":"Your API key identifier.\nRequired FormData field: metadata[api_key_id]\n","example":"lk_xyz789"}}}}},"project":{"type":"object","description":"Information about the project this upload belongs to","properties":{"project_id":{"type":"string","description":"Unique identifier for your project","example":"proj_abc123"},"project_name":{"type":"string","description":"Human-readable project name","example":"My Project"}}}}},"AssetDetailsResponse":{"type":"object","description":"Complete asset information after successful validation.\nThis is the response you receive when polling GET /api/v1/assets/{file_id} after upload.\n\n**Key Fields:**\n- `file_id`: Uploadcare UUID (use for future references)\n- `provider_file_url`: CDN URL to access the file\n- `thumbnail_url` / `preview_url`: ready-to-render previews (images only)\n- `media_kind`: image / video / audio / document\n- `image_info`: Rich metadata for images (dimensions, format, DPI)\n- `video_info`: Rich metadata for videos (duration, bitrate, codecs)\n\n**Using the Asset:**\n- Direct access: Use `provider_file_url` in your application\n- CDN transformations: Append Uploadcare operations to URL\n- Example: `{provider_file_url}-/resize/800x600/`\n","properties":{"asset":{"type":"object","description":"Complete asset object with all metadata","properties":{"_id":{"type":"string","description":"Internal database ID (MongoDB ObjectId)","example":"67890xyz"},"project_id":{"type":"string","description":"Project identifier that owns this asset","example":"proj_abc123"},"file_id":{"type":"string","description":"Uploadcare UUID - unique identifier for this file.\nUse this ID to reference the file in API calls.\n\nFiles uploaded from the dashboard carry the same UUID under\n`provider_file_id`; this endpoint accepts EITHER id, and always\nreturns it normalised as `file_id`.\n","example":"17be4678-dab7-4bc7-8753-28914a22960a"},"media_kind":{"type":"string","enum":["image","video","audio","document"],"description":"What kind of media this is, resolved from the MIME type and, when\nthat is missing or generic, from the filename extension.\n","example":"image"},"is_image":{"type":"boolean","description":"Shorthand for `media_kind == \"image\"`.","example":true},"thumbnail_url":{"type":"string","format":"uri","nullable":true,"description":"320px preview (longest edge, never upscaled), ready to drop into an\n`<img>`. `null` for video, audio and documents.\n","example":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/320x320/-/format/auto/-/quality/smart/"},"preview_url":{"type":"string","format":"uri","nullable":true,"description":"1024px version of `thumbnail_url` — for a detail view or a lightbox.\n`null` for non-images.\n","example":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/1024x1024/-/format/auto/-/quality/smart/"},"provider_file_name":{"type":"string","description":"Original filename from upload.\nPreserved from the file you uploaded.\n","example":"logo.png"},"file_mime_type":{"type":"string","description":"MIME type of the file.\nAutomatically detected by Uploadcare.\nExamples: image/png, video/mp4, application/pdf\n","example":"image/png"},"provider_file_url":{"type":"string","format":"uri","description":"CDN URL for direct access to the file.\nThis is the primary URL to use in your application.\nSupports Uploadcare transformations (resize, crop, format conversion).\nExample with transformation: {url}-/resize/800x600/-/quality/smart/\n","example":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/"},"provider_date_time_uploaded":{"type":"string","format":"date-time","description":"Timestamp when file was uploaded to Uploadcare.\nISO 8601 format.\n","example":"2025-10-09T10:30:00.000Z"},"file_size":{"type":"integer","description":"File size in bytes.\nUseful for displaying file size to users.\nConvert to KB/MB: bytes / 1024 / 1024\n","example":123456},"image_info":{"type":"object","nullable":true,"description":"Rich image metadata (only present for image files).\nAutomatically extracted during validation.\nIncludes dimensions, format, color information, and DPI.\n","properties":{"width":{"type":"integer","description":"Image width in pixels","example":1920},"height":{"type":"integer","description":"Image height in pixels","example":1080},"format":{"type":"string","description":"Image format (PNG, JPEG, GIF, WebP, SVG, etc.)\nUppercase format name.\n","example":"PNG"},"color_mode":{"type":"string","description":"Color mode of the image.\nCommon values: RGB, RGBA, CMYK, Grayscale\n","example":"RGB"},"dpi":{"type":"array","items":{"type":"integer"},"description":"DPI (dots per inch) information [horizontal, vertical].\nCommon values: [72, 72] for web, [300, 300] for print\n","example":[72,72]}}},"video_info":{"type":"object","nullable":true,"description":"Rich video metadata (only present for video files).\nAutomatically extracted during validation.\nIncludes duration, bitrate, and codec information.\n","properties":{"duration":{"type":"integer","description":"Video duration in milliseconds.\nConvert to seconds: duration / 1000\nExample: 30000ms = 30 seconds\n","example":30000},"bitrate":{"type":"integer","description":"Video bitrate in bits per second (bps).\nHigher bitrate = better quality, larger file size.\nExample: 1400000 bps ≈ 1.4 Mbps\n","example":1400000},"video_codec":{"type":"string","description":"Video codec used for compression.\nCommon values: h264, h265, vp8, vp9, av1\n","example":"h264"},"audio_codec":{"type":"string","description":"Audio codec used for compression.\nCommon values: aac, mp3, opus, vorbis\n","example":"aac"}}},"audio_info":{"type":"object","nullable":true,"description":"Rich audio metadata (only present for audio files).\nAutomatically extracted during validation.\n","properties":{"duration":{"type":"integer","description":"Audio duration in milliseconds","example":180000},"bitrate":{"type":"integer","description":"Audio bitrate in bits per second","example":320000},"codec":{"type":"string","description":"Audio codec (mp3, aac, opus, etc.)","example":"mp3"}}},"metadata":{"type":"object","nullable":true,"description":"Custom metadata attached to the file.\nContains project_id and api_key_id for tracking.\n","properties":{"project_id":{"type":"string","example":"proj_abc123"},"api_key_id":{"type":"string","example":"lk_xyz789"}}},"created_at":{"type":"string","format":"date-time","description":"Timestamp when asset was validated and saved to database.\nThis is a few seconds after upload (webhook processing time).\nISO 8601 format.\n","example":"2025-10-09T10:30:05.000Z"}}},"project":{"type":"object","description":"Project information for this asset","properties":{"project_id":{"type":"string","description":"Project identifier","example":"proj_abc123"},"project_name":{"type":"string","description":"Human-readable project name","example":"My Project"}}}}},"AssetsListResponse":{"type":"object","properties":{"assets":{"type":"array","items":{"type":"object","properties":{"_id":{"type":"string","description":"Internal database ID","example":"67890xyz"},"project_id":{"type":"string","description":"Project identifier","example":"proj_abc123"},"file_id":{"type":"string","description":"Uploadcare UUID. Always present — pass it to\n`GET /api/v1/assets/{file_id}` to fetch the full record.\n","example":"17be4678-dab7-4bc7-8753-28914a22960a"},"provider_file_name":{"type":"string","description":"Original filename","example":"logo.png"},"file_mime_type":{"type":"string","description":"MIME type","example":"image/png"},"provider_file_url":{"type":"string","format":"uri","description":"CDN URL","example":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/"},"provider_date_time_uploaded":{"type":"string","format":"date-time","description":"Upload timestamp","example":"2025-10-09T10:30:00.000Z"},"file_size":{"type":"integer","description":"File size in bytes. Absent on files uploaded from the dashboard\nbefore the metadata extractor ran — treat it as optional.\n","example":123456},"media_kind":{"type":"string","enum":["image","video","audio","document"],"description":"What kind of media this is, resolved from the MIME type and, when\nthat is missing or generic (`application/octet-stream`), from the\nfilename extension. Use it instead of parsing `file_mime_type`.\n","example":"image"},"is_image":{"type":"boolean","description":"Shorthand for `media_kind == \"image\"` — the only kind that has previews.","example":true},"thumbnail_url":{"type":"string","format":"uri","nullable":true,"description":"Ready-to-render CDN URL of a 320px preview (longest edge, never\nupscaled). `null` for video, audio and documents — Uploadcare has\nno on-the-fly still-frame endpoint for those.\n","example":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/320x320/-/format/auto/-/quality/smart/"},"preview_url":{"type":"string","format":"uri","nullable":true,"description":"Same as `thumbnail_url` at 1024px — the one to use for a lightbox or\na detail view. `null` for non-images.\n","example":"https://ucarecdn.com/17be4678-dab7-4bc7-8753-28914a22960a/-/preview/1024x1024/-/format/auto/-/quality/smart/"},"image_info":{"type":"object","nullable":true,"description":"Dimensions/format, when the file was analysed at upload."},"video_info":{"type":"object","nullable":true,"description":"Duration/bitrate/codecs, when the file was analysed at upload."},"audio_info":{"type":"object","nullable":true,"description":"Duration/bitrate/codec, when the file was analysed at upload."},"created_at":{"type":"string","format":"date-time","description":"Database creation timestamp","example":"2025-10-09T10:30:05.000Z"}}}},"total":{"type":"integer","description":"Total number of assets matching the filters, across every page.\nDivide by `limit` to know how many pages there are.\n","example":42},"count":{"type":"integer","description":"Number of assets in THIS page (`assets.length`).","example":50},"limit":{"type":"integer","description":"Requested limit","example":50},"offset":{"type":"integer","description":"Requested offset","example":0},"has_more":{"type":"boolean","description":"`true` when more assets remain after this page. Fetch the next one with\n`offset = offset + count`.\n","example":false},"project":{"type":"object","properties":{"project_id":{"type":"string","description":"Project identifier","example":"proj_abc123"},"project_name":{"type":"string","description":"Project name","example":"My Project"}}}}},"LogsResponse":{"type":"object","description":"Response containing visit or click log entries","required":["success","data"],"properties":{"success":{"type":"boolean","example":true},"data":{"type":"array","items":{"type":"object","properties":{"_id":{"type":"string","description":"Unique log entry identifier","example":"abc123"},"timestamp":{"type":"string","format":"date-time","description":"When the visit occurred","example":"2026-03-30T14:22:01.000Z"},"country":{"type":"string","description":"2-letter country code","example":"FR"},"city":{"type":"string","description":"City name","example":"Paris"},"u":{"type":"string","description":"The link slug","example":"my-link"},"referer":{"type":"string","description":"Referring URL (IPs in referer strings are masked)","example":"https://twitter.com"},"bot":{"type":"integer","description":"Whether the visitor is a bot (`0` = human, `1` = bot)","enum":[0,1],"example":0},"host":{"type":"string","description":"The domain that served the link","example":"linkscale.to"},"id":{"type":"string","description":"The link ID","example":"665a1f2e3b4c5d6e7f8a9b0c"},"userAgent":{"type":"string","description":"Browser user agent string (IPs embedded in user agent are masked)","example":"Mozilla/5.0 ..."},"device_type":{"type":"string","description":"Detected device type","example":"mobile"},"vpn":{"type":"integer","description":"Whether the visitor is using a VPN (`0` = no, `1` = yes)","enum":[0,1],"example":0},"spam":{"type":"integer","description":"Whether the visit is flagged as spam (`0` = no, `1` = yes)","enum":[0,1],"example":0},"clicks":{"type":"array","description":"Click events associated with this visit","items":{"type":"object","properties":{"url":{"type":"string","description":"The clicked destination URL","example":"https://example.com"},"btn_id":{"type":"string","description":"The button identifier","example":"cta-1"},"position":{"type":"integer","description":"Button position index","example":0},"created_at":{"type":"string","format":"date-time","description":"When the click occurred","example":"2026-03-30T14:22:03.000Z"}}}}}}}},"example":{"success":true,"data":[{"_id":"abc123","timestamp":"2026-03-30T14:22:01.000Z","country":"FR","city":"Paris","u":"my-link","referer":"https://twitter.com","bot":0,"host":"linkscale.to","id":"665a1f2e3b4c5d6e7f8a9b0c","userAgent":"Mozilla/5.0 ...","device_type":"mobile","vpn":0,"spam":0,"clicks":[{"url":"https://example.com","btn_id":"cta-1","position":0,"created_at":"2026-03-30T14:22:03.000Z"}]}]}},"SocialNetworksListResponse":{"type":"object","required":["social_networks"],"properties":{"social_networks":{"type":"array","description":"List of social network accounts for the project","items":{"type":"object","properties":{"_id":{"type":"string","description":"Unique identifier of the social network account","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"type":{"type":"string","description":"Social network platform type","enum":["instagram","twitter","tiktok","youtube","reddit","threads","telegram","facebook","snapchat"],"example":"instagram"},"handle":{"type":"string","description":"Username/handle on the platform","example":"example_account"},"display_name":{"type":"string","description":"Display name of the account","example":"Example Account"},"url":{"type":"string","description":"Profile URL on the platform","example":"https://instagram.com/example_account"},"folders":{"type":"array","description":"List of folder IDs the account belongs to","items":{"type":"string"},"example":["64f1a2b3c4d5e6f7a8b9c0d2"]},"created_at":{"type":"string","format":"date-time","description":"When the account was added to the project","example":"2024-01-15T10:00:00.000Z"},"latest_analysis":{"type":"object","description":"Most recent metrics snapshot from ClickHouse","properties":{"followers":{"type":"integer","description":"Current follower count","example":125000},"posts":{"type":"integer","description":"Total post count","example":342},"following":{"type":"integer","description":"Number of accounts followed","example":500},"engagement_rate":{"type":"number","format":"float","description":"Engagement rate percentage","example":3.5},"avg_likes":{"type":"number","description":"Average likes per post","example":4500},"avg_comments":{"type":"number","description":"Average comments per post","example":120},"profile_picture_url":{"type":"string","description":"URL of the profile picture","example":"https://cdn.example.com/pic.jpg"},"bio":{"type":"string","description":"Account bio text","example":"Account bio text"},"verified":{"type":"boolean","description":"Whether the account is verified","example":true},"snapshot_date":{"type":"string","description":"Date of the snapshot (YYYY-MM-DD)","example":"2024-06-01"},"snapshot_timestamp":{"type":"string","description":"Timestamp of the snapshot","example":"2024-06-01 12:00:00"},"fetched_at":{"type":"string","description":"When the data was fetched","example":"2024-06-01 12:05:00"}}},"last_post_date":{"type":"string","format":"date-time","description":"Date of the most recent post (only included when `include_last_post=true`)","example":"2024-05-30T15:30:00.000Z"}}}}}},"SocialNetworkDetailResponse":{"type":"object","required":["social_network","latest_analysis","recent_history"],"properties":{"social_network":{"type":"object","description":"Social network account details","properties":{"_id":{"type":"string","description":"Unique identifier","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"type":{"type":"string","description":"Social network platform type","enum":["instagram","twitter","tiktok","youtube","reddit","threads","telegram","facebook","snapchat"],"example":"instagram"},"handle":{"type":"string","description":"Username/handle on the platform","example":"example_account"},"display_name":{"type":"string","description":"Display name of the account","example":"Example Account"},"url":{"type":"string","description":"Profile URL on the platform","example":"https://instagram.com/example_account"},"folders":{"type":"array","description":"List of folder IDs","items":{"type":"string"},"example":[]},"created_at":{"type":"string","format":"date-time","description":"When the account was added","example":"2024-01-15T10:00:00.000Z"}}},"latest_analysis":{"type":"object","description":"Most recent metrics snapshot","properties":{"followers":{"type":"integer","example":125000},"posts":{"type":"integer","example":342},"following":{"type":"integer","example":500},"engagement_rate":{"type":"number","format":"float","example":3.5},"avg_likes":{"type":"number","example":4500},"avg_comments":{"type":"number","example":120},"snapshot_date":{"type":"string","example":"2024-06-01"},"snapshot_timestamp":{"type":"string","example":"2024-06-01 12:00:00"}}},"recent_history":{"type":"array","description":"Recent metrics history (last 30 snapshots)","items":{"type":"object","properties":{"followers":{"type":"integer","example":125000},"posts":{"type":"integer","example":342},"snapshot_date":{"type":"string","example":"2024-06-01"},"snapshot_timestamp":{"type":"string","example":"2024-06-01 12:00:00"}}}}}},"SocialNetworkHistoryResponse":{"type":"object","required":["social_network_id","history"],"properties":{"social_network_id":{"type":"string","description":"The social network account ID","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"history":{"type":"array","description":"Metrics history over time","items":{"type":"object","properties":{"followers":{"type":"integer","description":"Follower count at this snapshot","example":125000},"posts":{"type":"integer","description":"Post count at this snapshot","example":342},"following":{"type":"integer","description":"Following count at this snapshot","example":500},"engagement_rate":{"type":"number","format":"float","description":"Engagement rate at this snapshot","example":3.5},"avg_likes":{"type":"number","description":"Average likes per post","example":4500},"avg_comments":{"type":"number","description":"Average comments per post","example":120},"snapshot_date":{"type":"string","description":"Date of the snapshot (YYYY-MM-DD)","example":"2024-06-01"},"snapshot_timestamp":{"type":"string","description":"Timestamp of the snapshot","example":"2024-06-01 12:00:00"},"fetched_at":{"type":"string","description":"When the data was fetched","example":"2024-06-01 12:05:00"}}}}}},"SocialNetworkPostsListResponse":{"type":"object","required":["posts","total","limit","offset"],"properties":{"posts":{"type":"array","description":"List of posts with latest metrics","items":{"type":"object","properties":{"_id":{"type":"string","description":"Unique identifier of the post","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"social_network_id":{"type":"string","description":"ID of the social network account","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"shortcode":{"type":"string","description":"Platform-specific post identifier","example":"CxY1234567"},"text":{"type":"string","description":"Post caption/text","example":"Post caption here..."},"posted_at":{"type":"string","format":"date-time","description":"When the post was published","example":"2024-05-28T15:00:00.000Z"},"created_at":{"type":"string","format":"date-time","description":"When the post was added to the system","example":"2024-05-28T16:00:00.000Z"},"latest_analysis":{"type":"object","description":"Most recent metrics for the post","properties":{"metrics":{"type":"object","properties":{"likes":{"type":"integer","example":5200},"comments":{"type":"integer","example":142},"shares":{"type":"integer","example":89},"views":{"type":"integer","example":45000},"saves":{"type":"integer","example":320},"engagement_total":{"type":"integer","description":"Sum of all engagement metrics","example":5753},"engagement_rate":{"type":"number","format":"float","example":4.6},"snapshot_date":{"type":"string","example":"2024-06-01"}}}}},"links":{"type":"array","description":"Connected links","items":{"type":"object"}}}}},"total":{"type":"integer","description":"Total number of posts","example":342},"limit":{"type":"integer","description":"Items per page","example":20},"offset":{"type":"integer","description":"Number of items skipped","example":0}}},"SocialNetworkPostDetailResponse":{"type":"object","required":["post","latest_analysis","analysis_history","social_network"],"properties":{"post":{"type":"object","description":"Post details","properties":{"_id":{"type":"string","description":"Unique identifier of the post","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"social_network_id":{"type":"string","description":"ID of the social network account","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"shortcode":{"type":"string","description":"Platform-specific post identifier","example":"CxY1234567"},"text":{"type":"string","description":"Post caption/text","example":"Post caption..."},"media":{"type":"array","description":"Media attachments","items":{"type":"object"}},"posted_at":{"type":"string","format":"date-time","description":"When the post was published","example":"2024-05-28T15:00:00.000Z"}}},"latest_analysis":{"type":"object","description":"Most recent metrics","properties":{"likes":{"type":"integer","example":5200},"comments":{"type":"integer","example":142},"views":{"type":"integer","example":45000},"engagement_total":{"type":"integer","example":5753}}},"analysis_history":{"type":"array","description":"Historical metrics snapshots","items":{"type":"object","properties":{"likes":{"type":"integer","example":5200},"comments":{"type":"integer","example":142},"snapshot_date":{"type":"string","example":"2024-06-01"}}}},"social_network":{"type":"object","description":"Parent social network account info","properties":{"_id":{"type":"string","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"handle":{"type":"string","example":"example_account"},"type":{"type":"string","enum":["instagram","twitter","tiktok","youtube","reddit","threads","telegram","facebook","snapchat"],"example":"instagram"}}},"links":{"type":"array","description":"Connected links","items":{"type":"object"}}}},"SocialNetworkPostHistoryResponse":{"type":"object","required":["post_id","platform_post_id","history"],"properties":{"post_id":{"type":"string","description":"Internal post ID","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"platform_post_id":{"type":"string","description":"Platform-specific post identifier (shortcode)","example":"CxY1234567"},"history":{"type":"array","description":"Metrics history over time for the post","items":{"type":"object","properties":{"likes":{"type":"integer","example":5200},"comments":{"type":"integer","example":142},"shares":{"type":"integer","example":89},"views":{"type":"integer","example":45000},"saves":{"type":"integer","example":320},"engagement_total":{"type":"integer","description":"Sum of all engagement metrics","example":5753},"engagement_rate":{"type":"number","format":"float","example":4.6},"snapshot_date":{"type":"string","description":"Date of the snapshot (YYYY-MM-DD)","example":"2024-06-01"},"snapshot_timestamp":{"type":"string","description":"Timestamp of the snapshot","example":"2024-06-01 12:00:00"},"fetched_at":{"type":"string","description":"When the data was fetched","example":"2024-06-01 12:05:00"}}}}}},"SocialNetworkAnalyticsResponse":{"type":"object","required":["accounts_summary","accounts_followers_evolution","daily_followers_growth","daily_posts","total_followers","total_posts","total_growth","total_accounts"],"properties":{"accounts_summary":{"type":"array","description":"Summary of each social network account","items":{"type":"object","properties":{"_id":{"type":"string","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"type":{"type":"string","enum":["instagram","twitter","tiktok","youtube","reddit","threads","telegram","facebook","snapchat"],"example":"instagram"},"handle":{"type":"string","example":"example_account"},"display_name":{"type":"string","example":"Example Account"},"followers":{"type":"integer","description":"Current follower count","example":125000},"posts":{"type":"integer","description":"Current post count","example":342},"followers_growth":{"type":"integer","description":"Follower growth over the period","example":1200},"created_at":{"type":"string","format":"date-time","example":"2024-01-15T10:00:00.000Z"}}}},"accounts_followers_evolution":{"type":"array","description":"Daily follower evolution per account","items":{"type":"object","properties":{"_id":{"type":"string","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"type":{"type":"string","enum":["instagram","twitter","tiktok","youtube","reddit","threads","telegram","facebook","snapchat"],"example":"instagram"},"handle":{"type":"string","example":"example_account"},"display_name":{"type":"string","example":"Example Account"},"profile_picture_url":{"type":"string","example":"https://cdn.example.com/pic.jpg"},"current_followers":{"type":"integer","example":125000},"period_growth":{"type":"integer","description":"Total growth over the requested period","example":1200},"daily_history":{"type":"array","description":"Daily follower data points","items":{"type":"object","properties":{"date":{"type":"string","description":"Date (YYYY-MM-DD)","example":"2024-05-01"},"followers":{"type":"integer","example":123800},"growth":{"type":"integer","description":"Day-over-day growth","example":0}}}}}}},"daily_followers_growth":{"type":"array","description":"Aggregated daily follower growth across all accounts","items":{"type":"object","properties":{"date":{"type":"string","example":"2024-05-01"},"total_followers":{"type":"integer","description":"Sum of followers across all accounts","example":250000},"growth":{"type":"integer","description":"Day-over-day total growth","example":0},"accounts_count":{"type":"integer","description":"Number of accounts contributing","example":5}}}},"daily_posts":{"type":"array","description":"Daily post counts across all accounts","items":{"type":"object","properties":{"date":{"type":"string","example":"2024-05-01"},"total_posts":{"type":"integer","example":680},"accounts_count":{"type":"integer","example":5}}}},"total_followers":{"type":"integer","description":"Total followers across all accounts","example":500000},"total_posts":{"type":"integer","description":"Total posts across all accounts","example":1500},"total_growth":{"type":"integer","description":"Total follower growth over the period","example":5000},"total_accounts":{"type":"integer","description":"Number of social network accounts","example":5}}},"SocialNetworkTrendingResponse":{"type":"array","description":"Trending posts sorted by engagement growth","items":{"type":"object","properties":{"_id":{"type":"string","description":"Internal post ID","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"post_id":{"type":"string","description":"Platform-specific post identifier","example":"CxY1234567"},"social_network_id":{"type":"string","description":"ID of the social network account","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"text":{"type":"string","description":"Post caption/text","example":"This post went viral..."},"url":{"type":"string","description":"Direct URL to the post","example":"https://instagram.com/p/CxY1234567"},"posted_at":{"type":"string","format":"date-time","description":"When the post was published","example":"2024-05-28T15:00:00.000Z"},"social_network":{"type":"object","description":"Parent social network account info","properties":{"_id":{"type":"string","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"handle":{"type":"string","example":"example_account"},"type":{"type":"string","enum":["instagram","twitter","tiktok","youtube","reddit","threads","telegram","facebook","snapchat"],"example":"instagram"}}},"trending":{"type":"object","description":"Trending metrics comparing initial and current engagement","properties":{"analysis_count":{"type":"integer","description":"Number of analysis snapshots (minimum 3 to qualify)","example":8},"initial_engagement":{"type":"integer","description":"Engagement at the start of the period","example":1200},"current_engagement":{"type":"integer","description":"Current engagement total","example":5753},"engagement_growth":{"type":"integer","description":"Absolute engagement growth","example":4553},"engagement_growth_pct":{"type":"number","format":"float","description":"Engagement growth percentage","example":379.4},"current_likes":{"type":"integer","example":5200},"current_comments":{"type":"integer","example":142},"current_views":{"type":"integer","example":45000},"current_shares":{"type":"integer","example":89},"current_engagement_rate":{"type":"number","format":"float","example":4.6}}},"history":{"type":"array","description":"Engagement history data points","items":{"type":"object","properties":{"date":{"type":"string","description":"Date of the data point (YYYY-MM-DD)","example":"2024-05-29"},"engagement":{"type":"integer","example":1200},"likes":{"type":"integer","example":1000},"comments":{"type":"integer","example":50},"views":{"type":"integer","example":10000},"shares":{"type":"integer","example":20}}}}}}},"SocialNetworkFoldersResponse":{"type":"object","required":["folders"],"properties":{"folders":{"type":"array","description":"List of social network folders","items":{"type":"object","properties":{"_id":{"type":"string","description":"Unique identifier of the folder","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"name":{"type":"string","description":"Folder name","example":"Instagram Accounts"},"project_id":{"type":"string","description":"Project the folder belongs to","example":"proj_abc123"},"social_count":{"type":"integer","description":"Number of social network accounts in the folder","example":3},"created_at":{"type":"string","format":"date-time","description":"When the folder was created","example":"2024-01-15T10:00:00.000Z"}}}}}},"SocialNetworkFolderStatsResponse":{"type":"object","required":["project_id","folder_id","count","totals","by_platform","socials","last_refresh"],"properties":{"project_id":{"type":"string","description":"Project ID","example":"proj_abc123"},"folder_id":{"type":"string","description":"Folder ID","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"count":{"type":"integer","description":"Number of social network accounts in the folder","example":3},"totals":{"type":"object","description":"Aggregated totals across all accounts in the folder","properties":{"followers":{"type":"integer","example":350000},"posts":{"type":"integer","example":900},"likes":{"type":"integer","example":0},"views":{"type":"integer","example":0},"comments":{"type":"integer","example":0}}},"by_platform":{"type":"array","description":"Stats broken down by platform","items":{"type":"object","properties":{"platform":{"type":"string","enum":["instagram","twitter","tiktok","youtube","reddit","threads","telegram","facebook","snapchat"],"example":"instagram"},"count":{"type":"integer","description":"Number of accounts on this platform","example":2},"followers":{"type":"integer","example":250000},"posts":{"type":"integer","example":600},"likes":{"type":"integer","example":0},"views":{"type":"integer","example":0},"comments":{"type":"integer","example":0}}}},"socials":{"type":"array","description":"Individual social network account stats","items":{"type":"object","properties":{"_id":{"type":"string","example":"64f1a2b3c4d5e6f7a8b9c0d1"},"platform":{"type":"string","enum":["instagram","twitter","tiktok","youtube","reddit","threads","telegram","facebook","snapchat"],"example":"instagram"},"username":{"type":"string","example":"example_account"},"followers":{"type":"integer","example":125000},"posts":{"type":"integer","example":342},"likes":{"type":"integer","example":0},"views":{"type":"integer","example":0},"comments":{"type":"integer","example":0},"last_fetched_at":{"type":"string","format":"date-time","description":"When data was last fetched for this account","example":"2024-06-01T12:05:00.000Z"}}}},"last_refresh":{"type":"string","format":"date-time","description":"Most recent data refresh timestamp across all accounts","example":"2024-06-01T12:05:00.000Z"}}}}}}