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.
https://linkdrop.uk/api/v1Authentication
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"
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.
| Status | Meaning |
|---|---|
400 | Invalid input - the message says which field |
401 | Missing or invalid key |
402 | Plan does not include the API (or the feature you tried to set) |
403 | Read-only key on a write request |
404 | Page or link not found |
429 | Rate 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
/api/v1/meYour 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"] }
]
}
/api/v1/linksAll 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.
/api/v1/links/:idOne link. Returns { "link": { … } } with the same shape as above.
/api/v1/linksAdd a link. Returns 201 with the created link.
| Field | Type | Notes |
|---|---|---|
titlerequired | string | Button label, ≤ 120 chars |
urlrequired | string | https://… |
subtitle | string | Small text under the label, ≤ 200 chars |
publicTitle | string | Label shown to visitors when it should differ from title |
active | boolean | Default true. false hides the link without deleting it |
accentColor | string | Hex, e.g. #8b5cf6 |
imageUrl | string | Public https:// image for the button thumbnail |
position | "top" | number | Where 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" }'
/api/v1/links/:idChange 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 }'
/api/v1/links/:idRemoves the link. Returns { "ok": true, "id": "…" }. Its past clicks stay in your analytics.
/api/v1/links/batchCreate 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.
/api/v1/linksReplace 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", … ] }.
/api/v1/links/order{ "ids": [ "…", "…" ] } - reorders without touching content. Ids you leave out keep their relative order after the ones you listed.
Extra link fields
| Field | Notes |
|---|---|
teaser | true shows the link as a blurred teaser tile |
sec.turnstile | true asks a human check before the link opens (Pro+) |
sec.geo | { "mode": "allow" | "block" | "off", "countries": ["US","GB"], "redirect": "https://…" } (Pro+) |
imageUrl | Also accepts a data:image/…;base64,… upload (≤ 10 MB body) - it is stored on our CDN and the URL comes back in the response |
/api/v1/pageThe page itself: name, bio, accentColor, avatarUrl, avatarPos, banner, followers, verified, socials, linkCount, plus the summary fields from /me.
/api/v1/page| Field | Notes |
|---|---|
name | ≤ 80 chars |
bio | ≤ 500 chars |
accentColor | Hex |
avatarUrl | data:image/… upload or null to remove |
banner | data:image/… upload or null for none |
followers | Free text shown on the page, e.g. "12.3k" |
verified | boolean - the blue tick |
socials | { "instagram": "ella", "x": "ella", "fanvue": "ella", … } - keys: fanvue, onlyfans, instagram, threads, x, tiktok, snapchat, telegram |
Username, security rules and Direct Link stay dashboard-only.
/api/v1/pagesEvery page the key can reach - same list as /me.
/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.
/api/v1/pages/:idDeletes an extra page and its stats. The main page cannot be deleted.
/api/v1/analytics?days=30Totals 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.
/api/v1/campaigns?days=30Campaign 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" }
]
}
/api/v1/revenue?days=30Which 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 } ]
}
/api/v1/eventsRaw view and click events, oldest first - the right feed for syncing into a table row by row.
| Query | Notes |
|---|---|
since, until | ISO 8601 timestamps (until exclusive) |
type | view or click |
limit | 1-5000, default 500 |
cursor | Pass the previous response's nextCursor to get the next page |
campaign | Only 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.
| Event | When |
|---|---|
view | Someone opened your page |
click | Someone tapped a link |
link.created link.updated link.deleted links.replaced | Links changed through the API (dashboard edits do not fire these yet) |
page.created page.updated page.deleted | Page 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.
/api/v1/webhooksYour webhooks with failures, lastStatus, lastError, lastDeliveredAt.
/api/v1/webhookscurl -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.
/api/v1/webhooks/:idurl, events, active. Re-enabling resets the failure counter.
/api/v1/webhooks/:id/testSends a test event right now and returns what your endpoint answered: { "ok": true, "status": 200, "body": "…" }.
/api/v1/webhooks/:idRecipe: Airtable
- Create a Links table with fields
Title,URL,Subtitle,Active(checkbox) andLinkDrop ID. - 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 });
- A second automation on When record updated does the same with
PATCH /links/<LinkDrop ID>; a checkbox "Delete" can triggerDELETE. - 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 withPOST /webhooksforclick, and mapdata.linkId,data.source,data.country,data.campaigninto 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.