API v1 Dashboard →

LinkDrop API

Manage your links and page, pull views, clicks, campaigns and revenue into a spreadsheet, Airtable base or automation tool, and get every click pushed to you as a webhook. Plain JSON over HTTPS.

Included with the Business plan. Generate your key in the dashboard under Settings → Account → API. Base URL: https://linkdrop.uk/api/v1

Authentication

Send the key as a Bearer token on every request. x-api-key works too. Up to 10 named keys per account; a read-only key gets 403 on anything but GET.

curl https://linkdrop.uk/api/v1/me \
  -H "Authorization: Bearer ld_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
The full key is shown once, when you generate it. Treat it like a password: anyone holding it can change your links. If it leaks, Revoke it in the dashboard and generate a new one - the old key stops working instantly. Give each tool its own key so you can cut one without touching the others.

Pages

Every request acts on your main page by default. If you have extra link pages, pass ?page=<page id> on any endpoint to act on that page instead. Page ids come from GET /me.

GET /api/v1/links?page=9f2c1a4e-…

Errors & limits

Errors return a JSON body with an error message.

StatusMeaning
400Invalid input - the message says which field
401Missing or invalid key
402Plan does not include the API (or the feature you tried to set)
403Read-only key on a write request
404Page or link not found
429Rate limited - 120 requests per minute per key

Links are saved with the same validation as the dashboard: url must be http(s)://, title ≤ 120 chars, subtitle ≤ 200 chars, at most 100 links per page.

Endpoints

GET/api/v1/me

Your account and every page the key can reach.

{
  "id": "5c0e…", "username": "ella", "name": "Ella", "planTier": "business",
  "pages": [
    { "id": "5c0e…", "username": "ella",    "name": "Ella",  "url": "https://linkdrop.uk/@ella", "main": true,  "tags": [] },
    { "id": "9f2c…", "username": "ella-vip","name": "VIP",   "url": "https://links.ella.com",   "main": false, "tags": ["vip"] }
  ]
}

All links on the page, in display order. position is the zero-based index.

{
  "page": { "id": "5c0e…", "username": "ella", "url": "https://linkdrop.uk/@ella", "main": true, "tags": [] },
  "links": [
    { "id": "lm3k9x2a1b", "position": 0, "title": "My Fanvue", "publicTitle": null, "subtitle": "50% off today",
      "url": "https://www.fanvue.com/ella", "active": true, "kind": null, "imageUrl": null, "accentColor": null,
      "restricted": true }
  ]
}

restricted is true for adult-platform links - those get LinkDrop's protection layer automatically. kind: "post" marks a post-style tile.

One link. Returns { "link": { … } } with the same shape as above.

Add a link. Returns 201 with the created link.

FieldTypeNotes
titlerequiredstringButton label, ≤ 120 chars
urlrequiredstringhttps://…
subtitlestringSmall text under the label, ≤ 200 chars
publicTitlestringLabel shown to visitors when it should differ from title
activebooleanDefault true. false hides the link without deleting it
accentColorstringHex, e.g. #8b5cf6
imageUrlstringPublic https:// image for the button thumbnail
position"top" | numberWhere to insert. Default: bottom
curl -X POST https://linkdrop.uk/api/v1/links \
  -H "Authorization: Bearer ld_live_…" -H "Content-Type: application/json" \
  -d '{ "title": "New drop 🔥", "url": "https://www.fanvue.com/ella", "subtitle": "Only this week", "position": "top" }'

Change any of the fields above. Send only what changes; null clears a field. position moves the link.

curl -X PATCH https://linkdrop.uk/api/v1/links/lm3k9x2a1b \
  -H "Authorization: Bearer ld_live_…" -H "Content-Type: application/json" \
  -d '{ "active": false, "subtitle": null }'

Removes the link. Returns { "ok": true, "id": "…" }. Its past clicks stay in your analytics.

Create up to 100 links in one call. Body: { "links": [ {…}, {…} ], "position": "top" } - each item takes the same fields as POST /links. Returns 201 with { "links": [ … ] } in the order they were inserted. Nothing is saved if any item is invalid.

Replace the whole list - the spreadsheet is the source of truth. Items with an existing id are updated and keep their stats; items without one are created; links missing from the array are deleted. The array order becomes the page order.

curl -X PUT https://linkdrop.uk/api/v1/links \
  -H "Authorization: Bearer ld_live_…" -H "Content-Type: application/json" \
  -d '{ "links": [
        { "id": "lm3k9x2a1b", "title": "My Fanvue", "active": true },
        { "title": "New: custom video", "url": "https://www.fanvue.com/ella/shop" }
      ] }'

Response: { "links": [ … ], "removed": [ "id", … ] }.

{ "ids": [ "…", "…" ] } - reorders without touching content. Ids you leave out keep their relative order after the ones you listed.

Extra link fields

FieldNotes
teasertrue shows the link as a blurred teaser tile
sec.turnstiletrue asks a human check before the link opens (Pro+)
sec.geo{ "mode": "allow" | "block" | "off", "countries": ["US","GB"], "redirect": "https://…" } (Pro+)
imageUrlAlso accepts a data:image/…;base64,… upload (≤ 10 MB body) - it is stored on our CDN and the URL comes back in the response
imagePos{ "x": 50, "y": 50 } - focal point of the thumbnail in percent
kind"post" renders the link as a post tile instead of a button; null for a normal button
animpulse | glow | heartbeat | bounce | shake - attention animation on the button
animSpeedslow | normal | fast
animRate, animStrength1-100
animColorHex colour of the glow / pulse

Every field is writable through POST, PATCH, PUT and the batch call; null clears it.

GET/api/v1/page

The page itself: name, bio, accentColor, avatarUrl, avatarPos, banner, followers, verified, socials, linkCount, directLink, hasPassword, security, design, plus the summary fields from /me.

PATCH/api/v1/page

Send only what changes. Everything the dashboard's Profile and Security tabs can set is here.

FieldNotes
usernameRenames the page - its URL becomes /@newname. 3-30 chars, letters, digits, ., - and _. The old URL stops working, so update your bios
name≤ 80 chars
bio≤ 500 chars
accentColorHex
avatarUrldata:image/… upload or null to remove
avatarPos{ "x": 50, "y": 50 } focal point in percent
bannerdata:image/… upload or null for none
followersFree text shown on the page, e.g. "12.3k"
verifiedboolean - the blue tick
socials{ "instagram": "ella", "x": "ella", "fanvue": "ella", … } - keys: fanvue, onlyfans, instagram, threads, x, tiktok, snapchat, telegram
tags["vip", "promo"] - your own labels for the page (≤ 10, 24 chars each); [] clears
directLink{ "url": "https://…", "enabled": true } - visitors from Instagram skip the page and land on the URL (Pro+); null turns it off
securityMerged into the current rules, null removes one key:
vpnShield "off" | "standard" | "strict" · turnstile boolean (Pro+) · geo { "mode", "countries", "redirect" } as on links (Pro+) · decoyBio string, decoySocials { "instagram": "…", … } shown to crawlers · metaPixel, xPixel, gaId tracking ids · leaderboardHiddenViews, leaderboardHiddenEarnings booleans
curl -X PATCH https://linkdrop.uk/api/v1/page \
  -H "Authorization: Bearer ld_live_…" -H "Content-Type: application/json" \
  -d '{ "username": "ella-vip", "tags": ["vip"], "security": { "vpnShield": "strict", "geo": { "mode": "block", "countries": ["IN"] } } }'
GET/api/v1/page/design

The page's look: template, layout, background, fonts, button shape and colours, avatar, socials row, section order, message box and countdown. The response carries options - every enum, numeric range and colour key the server accepts - so a tool can build a form from it.

{
  "design": { "template": "noir", "layout": "auto", "bgStyle": "gradient", "bgColor": "#0b0b12", "bgColor2": "#3b1d6e",
              "fontStyle": "rounded", "buttonShape": "pill", "buttonFill": "glass", "buttonAnim": "none",
              "order": ["socials", "followers", "bio", "links", "posts", "msg"],
              "msgBox": { "enabled": true, "url": "https://…", "placeholder": "Say hi" },
              "countdown": { "enabled": true, "text": "Offer ends in", "minutes": 60 }, … },
  "options": { "enums": { "bgStyle": ["blur","solid","gradient"], … }, "ranges": { "fontScale": [80,140], … },
               "colors": ["bgColor","bgColor2","textColor","buttonTextColor"], "sections": [ … ] }
}
PATCH/api/v1/page/design

Send any subset of the fields above; null resets one to its default. Validated exactly like the dashboard's Design tab (page design is Pro+; on a lower plan the values are stored but not rendered).

curl -X PATCH https://linkdrop.uk/api/v1/page/design \
  -H "Authorization: Bearer ld_live_…" -H "Content-Type: application/json" \
  -d '{ "buttonShape": "pill", "buttonFill": "solid", "bgStyle": "solid", "bgColor": "#111111", "countdown": { "enabled": true, "text": "Sale ends in", "minutes": 120 } }'
PUT/api/v1/page/password

{ "password": "secret" } puts the whole page behind a password (4-100 chars); { "password": null } removes it. The password itself is never returned - GET /page only reports hasPassword.

GET/api/v1/pages

Every page the key can reach - same list as /me.

POST/api/v1/pages

{ "username": "ella-vip", "name": "VIP", "cloneFrom": "<page id>" } creates an extra link page (Pro+, within your plan's page count). cloneFrom copies design and links from another of your pages. Returns 201 with the new page - use its id as ?page= from then on.

DELETE/api/v1/pages/:id

Deletes an extra page and its stats. The main page cannot be deleted.

GET/api/v1/analytics?days=30

Totals for the last days days (1-365, default 30), bucketed per day in the Europe/Bratislava timezone.

{
  "page": { … }, "days": 30, "since": "2026-08-22T22:00:00.000Z", "truncated": false,
  "totals": { "views": 4210, "clicks": 1388, "ctr": 33 },
  "byDay":  [ { "date": "2026-08-23", "views": 120, "clicks": 41 }, … ],
  "byLink": [ { "id": "lm3k9x2a1b", "title": "My Fanvue", "clicks": 902 }, … ],
  "bySource":  { "instagram": { "views": 3100, "clicks": 1002 }, "direct": { … } },
  "byCountry": { "US": { "views": 1800, "clicks": 610 }, "GB": { … } },
  "byCampaign": { "story-sep20": { "views": 410, "clicks": 190 } },
  "byDevice":  { "mobile": 3900, "tablet": 40, "desktop": 270, "unknown": 0 }
}

Add &campaign=<code> to get the same report for one campaign only.

GET/api/v1/campaigns?days=30

Campaign links need no setup: share your page as https://linkdrop.uk/@ella?c=story-sep20 (or your custom domain) and every view and click from that link carries the code. Codes are lower-case letters, digits, - and _, up to 32 chars. Put a different code in every post, story or ad and this endpoint tells you which one worked.

{
  "howTo": "https://linkdrop.uk/@ella?c=",
  "campaigns": [
    { "code": "story-sep20", "url": "https://linkdrop.uk/@ella?c=story-sep20",
      "views": 410, "clicks": 190, "ctr": 46.3,
      "byLink": [ { "id": "lm3k9x2a1b", "title": "My Fanvue", "clicks": 150 } ],
      "firstSeen": "2026-09-20T08:01:11Z", "lastSeen": "2026-09-20T21:40:02Z" }
  ]
}
GET/api/v1/revenue?days=30

Which link makes money - estimated. Needs Fanvue connected in the dashboard. Fanvue reports account-level net only, so each day's net is split across your earning links (Fanvue links, else all 18+ links) in proportion to that day's clicks. Days that earned with no clicks on any earning link land in unattributedRevenue (renewals, DMs, traffic that never touched LinkDrop). 7-90 days.

{
  "estimated": true, "connected": true, "days": 30, "coverageDays": 27,
  "totalRevenue": 4180.5, "attributedRevenue": 3120.0, "unattributedRevenue": 1060.5,
  "links": [ { "id": "lm3k9x2a1b", "title": "My Fanvue", "url": "https://www.fanvue.com/ella",
               "clicks": 902, "revenue": 2410.2, "revenuePerClick": 2.67 } ]
}
GET/api/v1/events

Raw view and click events, oldest first - the right feed for syncing into a table row by row.

QueryNotes
since, untilISO 8601 timestamps (until exclusive)
typeview or click
limit1-5000, default 500
cursorPass the previous response's nextCursor to get the next page
campaignOnly events from that campaign code
{
  "events": [
    { "id": 8812031, "type": "click", "linkId": "lm3k9x2a1b", "linkTitle": "My Fanvue",
      "source": "instagram", "country": "US", "device": "mobile", "campaign": "story-sep20", "at": "2026-09-20T09:14:02.331Z" }
  ],
  "nextCursor": "8812031"
}

Store the last id you processed and call again with cursor=<that id> - you only ever receive new events, no duplicates. Prefer a webhook if you want events as they happen.

Webhooks

Register an https URL and LinkDrop POSTs a JSON body to it as things happen - no polling. Up to 5 webhooks per account.

EventWhen
viewSomeone opened your page
clickSomeone tapped a link
link.created link.updated link.deleted links.replacedLinks changed through the API (dashboard edits do not fire these yet)
page.created page.updated page.deletedPage changed through the API
POST https://your-endpoint.example/linkdrop
Content-Type: application/json
X-LinkDrop-Event: click
X-LinkDrop-Delivery: 2f6c…            (unique per delivery - use it to dedupe)
X-LinkDrop-Signature: t=1758360000,v1=9a3f…

{ "id": "2f6c…", "type": "click", "createdAt": "2026-09-20T09:14:02.331Z", "pageId": "5c0e…",
  "data": { "type": "click", "linkId": "lm3k9x2a1b", "source": "instagram", "country": "US", "device": "mobile", "campaign": "story-sep20" } }

Verify the signature

Every webhook has a secret (shown once, when you create it). v1 is HMAC-SHA256 of <t>.<raw body> with that secret. Reject if it does not match or t is older than 5 minutes.

const crypto = require('crypto');
function verify(rawBody, header, secret) {
  const t = header.match(/t=(\d+)/)[1], v1 = header.match(/v1=([a-f0-9]+)/)[1];
  const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)) && Date.now() / 1000 - t < 300;
}

Delivery & retries

Answer with any 2xx within 8 seconds. Anything else is retried after 1 s, 10 s and 60 s. After 20 deliveries in a row that failed every retry the webhook is switched off (active: false) - fix your endpoint and re-enable it with PATCH { "active": true }. Deliveries are at-least-once; dedupe on X-LinkDrop-Delivery.

GET/api/v1/webhooks

Your webhooks with failures, lastStatus, lastError, lastDeliveredAt.

POST/api/v1/webhooks
curl -X POST https://linkdrop.uk/api/v1/webhooks \
  -H "Authorization: Bearer ld_live_…" -H "Content-Type: application/json" \
  -d '{ "url": "https://hook.eu1.make.com/abc123", "events": ["click", "link.updated"] }'

{ "webhook": { "id": "…", "url": "…", "events": ["click","link.updated"], "active": true, "secret": "whsec_…" } }

Omit events to subscribe to everything. The URL must be public https - localhost and private networks are refused.

PATCH/api/v1/webhooks/:id

url, events, active. Re-enabling resets the failure counter.

POST/api/v1/webhooks/:id/test

Sends a test event right now and returns what your endpoint answered: { "ok": true, "status": 200, "body": "…" }.

DELETE/api/v1/webhooks/:id

Recipe: Airtable

  1. Create a Links table with fields Title, URL, Subtitle, Active (checkbox) and LinkDrop ID.
  2. Automation → trigger When record created → action Run script:
const KEY = 'ld_live_…';
const { recordId } = input.config();
const table = base.getTable('Links');
const rec = await table.selectRecordAsync(recordId);

const r = await fetch('https://linkdrop.uk/api/v1/links', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer ' + KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: rec.getCellValueAsString('Title'),
    url: rec.getCellValueAsString('URL'),
    subtitle: rec.getCellValueAsString('Subtitle') || undefined,
    active: !!rec.getCellValue('Active'),
  }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error);
await table.updateRecordAsync(recordId, { 'LinkDrop ID': data.link.id });
  1. A second automation on When record updated does the same with PATCH /links/<LinkDrop ID>; a checkbox "Delete" can trigger DELETE.
  2. For a Clicks table, either run a scheduled script every hour that calls /events?cursor=<last id> and creates one record per event, or - live - create an Airtable automation with the When webhook received trigger, register its URL with POST /webhooks for click, and map data.linkId, data.source, data.country, data.campaign into the record.

Recipe: Make / n8n / Zapier

Use the generic HTTP module. Method and URL from the tables above, header Authorization: Bearer ld_live_…, body type JSON. Map the response's link.id back into your source record so later steps can update or delete it. To react to clicks, add a Custom webhook trigger, copy its URL into POST /webhooks and every click starts the scenario with the payload above.

Questions or a missing endpoint? Write to hello@linkdrop.uk.