v1 · Production
The each.bio API
Manage creators, links, customization and analytics with one Bearer token. Requests and responses are JSON (file uploads use multipart), and errors follow RFC 9457.
Base URL
https://linkswith.me/api/v1Auth
Bearer lwm_sk_live_…Format
application/jsonQuick start
- Create an API key in /dashboard/api. The plaintext is shown once on creation — store it immediately.
- Send it on every request as
Authorization: Bearer …. - Pick an endpoint below. Each one ships curl, JavaScript, and Python snippets you can copy.
# Verify your key
curl https://linkswith.me/api/v1/me \
-H "Authorization: Bearer lwm_sk_live_…"
# Create a managed creator
curl -X POST https://linkswith.me/api/v1/creators \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{"slug":"jane","display_name":"Jane","bio":"Find all my links below!"}'
# Add an OnlyFans link
curl -X POST https://linkswith.me/api/v1/creators/42/links \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{"label":"My OnlyFans","url":"https://onlyfans.com/jane","icon":"onlyfans"}'const KEY = process.env.LWM_API_KEY;
const headers = {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
};
// Verify your key
const me = await fetch('https://linkswith.me/api/v1/me', { headers })
.then(r => r.json());
// Create a managed creator
const creator = await fetch('https://linkswith.me/api/v1/creators', {
method: 'POST',
headers,
body: JSON.stringify({ slug: 'jane', display_name: 'Jane', bio: 'Find all my links below!' }),
}).then(r => r.json());
// Add an OnlyFans link
const link = await fetch(`https://linkswith.me/api/v1/creators/${creator.id}/links`, {
method: 'POST',
headers,
body: JSON.stringify({ label: 'My OnlyFans', url: 'https://onlyfans.com/jane', icon: 'onlyfans' }),
}).then(r => r.json());import os, httpx
key = os.environ['LWM_API_KEY']
client = httpx.Client(
base_url='https://linkswith.me/api/v1',
headers={'Authorization': f'Bearer {key}'},
)
# Verify your key
me = client.get('/me').json()
# Create a managed creator
creator = client.post('/creators', json={
'slug': 'jane',
'display_name': 'Jane',
'bio': 'Find all my links below!',
}).json()
# Add an OnlyFans link
link = client.post(f"/creators/{creator['id']}/links", json={
'label': 'My OnlyFans',
'url': 'https://onlyfans.com/jane',
'icon': 'onlyfans',
}).json()Authentication
Every request needs an Authorization: Bearer header. Keys look like lwm_sk_live_4eC39HqLyjWDarjtT1zdp7dcA4f9. They carry a role and an optional scope.
read- List + read everything in scope, including analytics.
writeread+ create / update / delete links and customization.admin- All of the above + manage creators and other API keys.
Scope is either "all" (every creator on the account) or an array of creator IDs the key may act on. Out-of-scope requests get a 403. Keys are SHA-256 hashed at rest. The plaintext is shown once on creation; rotate if you lose it.
Errors
All errors follow RFC 9457 Problem Details with two extra fields: a stable string code for branching, and a request_id you can paste in support requests.
{
"type": "https://docs.linkswith.me/errors/invalid_request",
"title": "Invalid Request",
"status": 400,
"detail": "url must be a valid absolute http:// or https:// URL.",
"code": "invalid_request",
"param": "url",
"request_id": "req_a9f8b7c6d5e4"
}| HTTP | Code | Meaning |
|---|---|---|
400 | invalid_request | Bad JSON, missing required field, malformed value. |
401 | unauthorized | Missing / malformed / unknown Bearer token. |
403 | forbidden | Wrong role, or creator out of the key's scope. Also "This account is suspended." |
403 | plan_limit_exceeded | The account's plan doesn't cover this: its page limit is reached, or API keys / webhooks aren't part of it. Accounts on unlimited plans never receive it. |
404 | not_found | Resource doesn't exist or isn't yours. |
409 | conflict | Slug already taken, key name collision. |
422 | unprocessable | Validation passed shape but failed semantics. |
500 | internal | Server bug — paste the request_id in support. The detail is always "Something went wrong (request req_…)". |
plan_limit_exceeded carries extra fields: limit_type (pages, api, webhooks, …), plan, upgrade_url, and for pages also limit and used. Pages and data that already exist stay live; only creating more is refused.
{
"type": "https://docs.linkswith.me/errors/plan_limit_exceeded",
"title": "Plan Limit Exceeded",
"status": 403,
"detail": "Your Free plan includes 5 pages and this account has 5. Existing pages stay live; upgrade to add more.",
"code": "plan_limit_exceeded",
"request_id": "req_a9f8b7c6d5e4",
"limit_type": "pages",
"limit": 5,
"used": 5,
"plan": "free",
"upgrade_url": "https://linkswith.me/dashboard/settings/billing"
}Rate limits
Each API key gets a token bucket: 60 requests / minute sustained, with a burst capacity of 120. Tokens refill at 1 / second. Every response carries the current state in headers — read them before launching bulk loops.
X-RateLimit-Limitinteger- Sustained rate per minute. Currently
60for every key. X-RateLimit-Remaininginteger- Tokens left in the bucket right now.
X-RateLimit-Resetinteger- Seconds until the bucket is full again.
Retry-Afterinteger- Only on
429. Seconds to wait before retrying. X-Request-Idstring- On every error response. Quote it in support requests.
When you blow the limit you get 429 Rate Limited with a Retry-After header. For bulk loops, throttle yourself to ~10 req/s (pLimit(5) is a safe starting point) so you stay well under the burst ceiling and don't trip Cloudflare's separate per-IP limits.
Pagination
List endpoints that return many rows use cursor pagination. Currently that's GET /v1/creators. Pass ?limit=N (default 100, max 500) and ?cursor=<next_cursor> from the previous page. Walk until has_more = false.
{
"data": [/* …creators… */],
"next_cursor": "42",
"has_more": true
}Other list endpoints (/links, /api_keys, /webhook_endpoints) return flat arrays — they don't paginate today because they max out at small N. The /analytics/activity endpoint accepts ?limit=N (max 100) but doesn't cursor.
CORS
Every /api/v1/*, /api/widgets/*, and /openapi response carries Access-Control-Allow-Origin: *. Preflight OPTIONS requests are answered at the edge with the right headers. Allowed methods: GET, POST, PATCH, DELETE. Allowed headers: Authorization, Content-Type, Idempotency-Key, X-Webhook-Signature.
Security note: the API uses Authorization: Bearer tokens (not cookies). Calling it from a browser leaks your secret to anyone who can inspect that page. Always proxy through your own backend in production. The wildcard CORS is for first-party admin dashboards on dev origins.
Resource model
- User — your each.bio account. Just one per token.
- Creator — a public link page reachable at
{primary_host}/{slug}(e.g.linkswith.me/jane). Every creator is assigned one of the active each.bio domains at creation —linkswith.meor one of the backup domains — but the page is reachable on every domain regardless. The set can change;available_hostsonGET /melists the domains your new creators can get. each.bio may later move a creator to another domain (for example after Instagram flags one). Domain moves by each.bio don't send webhooks; GET the creator to seeprimary_host. The old URL keeps working. An individual account has one creator (themselves); an agency has itself + every managed creator. - Link — a button on a creator's page. Has a
label,url, aniconservice key (onlyfans,fansly,instagram, …), aposition, and anactiveflag. - Customization — every visual + widget setting on a creator's page (theme, colours, live widgets, age gate, dominant CTA, countdown, hero media). Treated as a singleton sub-resource of a creator.
- API key — your credentials. Manage them in the dashboard or via this API.
Account
/meWho am I?
plan, its limits (pages: null means unlimited) and the available_hosts a new creator can get. Each of these blocks is left out if it can't be read right now, so treat them as optional.Parameters
include- Optional.
usageaddsusage.pages(pages the account has, its own page included).
Request
curl https://linkswith.me/api/v1/me \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/me', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/me",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"id": 1,
"slug": "agencyowner",
"email": "[email protected]",
"display_name": "Agency Owner",
"account_type": "agency",
"primary_host": "linkswith.me",
"key": { "id": 7, "role": "admin", "scope": "all" },
"plan": { "code": "agency", "name": "Agency", "state": "active", "period_end": "2026-11-12T00:00:00.000Z" },
"limits": { "pages": null },
"available_hosts": ["linkswith.me", "backup1.example", "backup2.example", "backup3.example"]
}{
"type": "https://docs.linkswith.me/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Provide a valid Bearer API key.",
"code": "unauthorized",
"request_id": "req_a9f8b7c6d5e4"
}API keys
All endpoints in this section require an admin role. A key only sees and manages keys whose scope lies within its own scope (plus the account's own profile); other keys are left out of the list and return 404 by id. Creating or re-scoping a key with a scope wider than the calling key's gets 403 forbidden; only a key with scope "all" can grant "all". A full-access key (admin, "all") reaches every key.
/api_keysList API keys
Request
curl https://linkswith.me/api/v1/api_keys \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/api_keys', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/api_keys",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"data": [
{
"id": 7,
"name": "Zapier integration",
"role": "admin",
"scope": "all",
"prefix": "lwm_sk_live_4eC3",
"last4": "A4f9",
"created_at": "2026-05-09T22:01:14Z",
"last_used_at": "2026-05-10T11:42:09Z",
"last_used_ip": "203.0.113.42",
"revoked": false
}
]
}/api_keysCreate a new API key
403 plan_limit_exceeded (limit_type: "api").Body
name- Human-readable label, shown in the dashboard.
role- "read", "write", or "admin". Defaults to "admin".
scope- "all" or an explicit array of creator IDs the key may act on. Defaults to the calling key's own scope ("all" for a full-access key). Must lie within the calling key's scope, otherwise 403 forbidden; an empty array also means "all".
Request
curl -X POST https://linkswith.me/api/v1/api_keys \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Zapier integration",
"role": "admin",
"scope": "all"
}'const res = await fetch('https://linkswith.me/api/v1/api_keys', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "Zapier integration",
"role": "admin",
"scope": "all"
}),
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/api_keys",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"name": "Zapier integration",
"role": "admin",
"scope": "all"
},
)
data = r.json()Response
{
"id": 7,
"name": "Zapier integration",
"role": "admin",
"scope": "all",
"prefix": "lwm_sk_live_4eC3",
"last4": "A4f9",
"created_at": "2026-05-09T22:01:14Z",
"last_used_at": null,
"last_used_ip": "",
"revoked": false,
"plaintext": "lwm_sk_live_4eC39HqLyjWDarjtT1zdp7dcA4f9"
}/api_keys/ {id}Retrieve a key
Parameters
id- API key id.
Request
curl https://linkswith.me/api/v1/api_keys/{id} \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/api_keys/{id}', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/api_keys/{id}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"id": 7,
"name": "Zapier integration",
"role": "admin",
"scope": "all",
"prefix": "lwm_sk_live_4eC3",
"last4": "A4f9",
"created_at": "2026-05-09T22:01:14Z",
"last_used_at": "2026-05-10T11:42:09Z",
"last_used_ip": "203.0.113.42",
"revoked": false
}/api_keys/ {id}Update a key
Parameters
id- API key id.
Body
name- New name.
scope- New scope. Must lie within the calling key's scope, otherwise 403 forbidden.
Request
curl -X PATCH https://linkswith.me/api/v1/api_keys/{id} \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Zapier integration",
"scope": "all"
}'const res = await fetch('https://linkswith.me/api/v1/api_keys/{id}', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "Zapier integration",
"scope": "all"
}),
});
const data = await res.json();import os, httpx
r = httpx.patch(
"https://linkswith.me/api/v1/api_keys/{id}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"name": "Zapier integration",
"scope": "all"
},
)
data = r.json()Response
{
"id": 7,
"name": "Renamed integration",
"role": "admin",
"scope": [42, 43],
"prefix": "lwm_sk_live_4eC3",
"last4": "A4f9",
"created_at": "2026-05-09T22:01:14Z",
"last_used_at": "2026-05-10T11:42:09Z",
"last_used_ip": "203.0.113.42",
"revoked": false
}/api_keys/ {id}Revoke a key
Parameters
id- API key id.
Request
curl -X DELETE https://linkswith.me/api/v1/api_keys/{id} \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/api_keys/{id}', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.delete(
"https://linkswith.me/api/v1/api_keys/{id}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{ "revoked": true }/api_keys/ {id}/ rotateRotate a key
Parameters
id- API key id to rotate.
Request
curl -X POST https://linkswith.me/api/v1/api_keys/{id}/rotate \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/api_keys/{id}/rotate', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/api_keys/{id}/rotate",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"id": 8,
"name": "Zapier integration",
"role": "admin",
"scope": "all",
"prefix": "lwm_sk_live_q9X2",
"last4": "K3p1",
"created_at": "2026-05-10T18:14:33Z",
"last_used_at": null,
"last_used_ip": "",
"revoked": false,
"plaintext": "lwm_sk_live_q9X2bAWxhc7VrZpYvF4LdoMzg8K3p1"
}Creators
A creator is a public link page reachable at {primary_host}/{slug}. Agencies use POST /creators to spin up new managed pages; individuals always work with a single creator (themselves). Each creator is assigned one of the active each.bio domains (linkswith.me or one of the backup domains) as their primary_host on creation (weighted; set by each.bio), but the page is reachable on every domain regardless. The set can change; available_hosts on GET /me lists the domains your new creators can get. each.bio may later move a creator to another domain (for example after Instagram flags one). Domain moves by each.bio don't send webhooks; GET the creator to see primary_host. The old URL keeps working.
/creatorsList creators
Parameters
limit- Page size. Default 100, max 500.
cursor- Cursor returned as
next_cursorfrom the previous page. Omit on first call.
Request
curl https://linkswith.me/api/v1/creators \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"data": [
{
"id": 1,
"slug": "agencyowner",
"display_name": "Agency Owner",
"bio": null,
"avatar_url": null,
"account_type": "agency",
"managed_by": null,
"primary_host": "linkswith.me",
"created_at": "2026-01-15T08:00:00Z"
},
{
"id": 42,
"slug": "jane",
"display_name": "Jane",
"bio": "Find all my links below!",
"avatar_url": "https://cdn.example.com/avatars/jane.jpg",
"account_type": "managed",
"managed_by": 1,
"primary_host": "linkswith.me",
"created_at": "2026-04-01T18:42:11Z"
}
],
"next_cursor": "42",
"has_more": true
}/creatorsCreate a managed creator
slug becomes their public URL: {primary_host}/{slug}. If primary_host is omitted, the server picks one (weighted; set by each.bio) to distribute new creators across domains. The page is reachable on every domain regardless. each.bio may later move a creator to another domain (for example after Instagram flags one). Domain moves by each.bio don't send webhooks; GET the creator to see primary_host. The old URL keeps working. The slugs api, dashboard, docs, go, legal, login, openapi, register and terms are used by the app itself and return 400 invalid_request. Each new creator counts toward the account's page limit (its own page included); at the limit the call returns 403 plan_limit_exceeded. Accounts on unlimited plans never receive it.Body
slug- 3-30 chars. Lowercase letters, digits, _ or -. Cannot start or end with _/-. Cannot be changed after creation.
display_name- Visible name on the public page.
bio- Short tagline shown under the avatar.
primary_host- Optional. One of the active each.bio domains: linkswith.me or one of your account's backup domains. The set can change; available_hosts on /me lists them. An invalid value returns 400. Omit to let the server pick one (recommended). each.bio can move a creator to another domain later; that sends no webhook.
Request
curl -X POST https://linkswith.me/api/v1/creators \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"slug": "jane",
"display_name": "Jane",
"bio": "Find all my links below!",
"primary_host": ""
}'const res = await fetch('https://linkswith.me/api/v1/creators', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"slug": "jane",
"display_name": "Jane",
"bio": "Find all my links below!",
"primary_host": ""
}),
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/creators",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"slug": "jane",
"display_name": "Jane",
"bio": "Find all my links below!",
"primary_host": ""
},
)
data = r.json()Response
{
"id": 42,
"slug": "jane",
"display_name": "Jane",
"bio": "Find all my links below!",
"avatar_url": null,
"account_type": "managed",
"managed_by": 1,
"primary_host": "linkswith.me",
"created_at": "2026-04-01T18:42:11Z"
}{
"type": "https://docs.linkswith.me/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Slug \"jane\" is already taken.",
"code": "conflict",
"param": "slug",
"request_id": "req_a9f8b7c6d5e4"
}/creators/ {id}Retrieve a creator
Parameters
id- Creator id.
Request
curl https://linkswith.me/api/v1/creators/{id} \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators/{id}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"id": 42,
"slug": "jane",
"display_name": "Jane",
"bio": "Find all my links below!",
"avatar_url": "https://cdn.example.com/avatars/jane.jpg",
"account_type": "managed",
"managed_by": 1,
"primary_host": "linkswith.me",
"created_at": "2026-04-01T18:42:11Z"
}/creators/ {id}Update a creator's profile
Parameters
id- Creator id.
Body
display_name- New display name.
bio- New bio.
avatar_url- External URL or data: URL (≤ 256×256, jpeg).
Request
curl -X PATCH https://linkswith.me/api/v1/creators/{id} \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Jane",
"bio": "Find all my links below!",
"avatar_url": "https://cdn.example.com/avatars/jane.jpg"
}'const res = await fetch('https://linkswith.me/api/v1/creators/{id}', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"display_name": "Jane",
"bio": "Find all my links below!",
"avatar_url": "https://cdn.example.com/avatars/jane.jpg"
}),
});
const data = await res.json();import os, httpx
r = httpx.patch(
"https://linkswith.me/api/v1/creators/{id}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"display_name": "Jane",
"bio": "Find all my links below!",
"avatar_url": "https://cdn.example.com/avatars/jane.jpg"
},
)
data = r.json()Response
{
"id": 42,
"slug": "jane",
"display_name": "Jane Doe",
"bio": "Updated bio with new links!",
"avatar_url": "https://cdn.example.com/avatars/jane.jpg",
"account_type": "managed",
"managed_by": 1,
"primary_host": "linkswith.me",
"created_at": "2026-04-01T18:42:11Z"
}/creators/ {id}Delete a managed creator
Parameters
id- Creator id.
Request
curl -X DELETE https://linkswith.me/api/v1/creators/{id} \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.delete(
"https://linkswith.me/api/v1/creators/{id}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{ "deleted": true }/slug-availabilityCheck if a slug is available
Parameters
slug- The slug to check. Lowercased + trimmed before validation.
Request
curl https://linkswith.me/api/v1/slug-availability \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/slug-availability', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/slug-availability",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{ "slug": "jane", "available": true }{
"slug": "jane",
"available": false,
"reason": "slug is already taken"
}Links
Buttons on a creator's page. icon is one of the supported service keys:
onlyfans, fansly, instagram, tiktok, x, youtube, twitch, spotify, snapchat, telegram, discord, reddit, amazon, cashapp, linktree, website, link
/creators/ {id}/ linksList a creator's links
Parameters
id- Creator id.
Request
curl https://linkswith.me/api/v1/creators/{id}/links \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}/links', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators/{id}/links",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"data": [
{ "id": 40, "user_id": 42, "label": "My OnlyFans", "url": "https://onlyfans.com/jane", "icon": "onlyfans", "position": 1, "active": 1, "created_at": "2026-04-02T09:17:33Z" },
{ "id": 41, "user_id": 42, "label": "Instagram", "url": "https://instagram.com/jane", "icon": "instagram", "position": 2, "active": 1, "created_at": "2026-04-02T09:18:01Z" }
]
}/creators/ {id}/ linksCreate a link
Parameters
id- Creator id.
Body
label- Visible button text.
url- Absolute http:// or https:// URL with a hostname. Other schemes (javascript:, data:, mailto:, tel:) and relative paths are rejected with 400 invalid_request.
icon- Service key (see list above). Defaults to "link".
active- Whether to show on the page. Defaults to true.
Request
curl -X POST https://linkswith.me/api/v1/creators/{id}/links \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"label": "My OnlyFans",
"url": "https://onlyfans.com/jane",
"icon": "onlyfans",
"active": true
}'const res = await fetch('https://linkswith.me/api/v1/creators/{id}/links', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"label": "My OnlyFans",
"url": "https://onlyfans.com/jane",
"icon": "onlyfans",
"active": true
}),
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/creators/{id}/links",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"label": "My OnlyFans",
"url": "https://onlyfans.com/jane",
"icon": "onlyfans",
"active": True
},
)
data = r.json()Response
{
"id": 1024,
"user_id": 42,
"label": "My OnlyFans",
"url": "https://onlyfans.com/jane",
"icon": "onlyfans",
"position": 1,
"active": 1,
"created_at": "2026-05-10T18:14:33Z"
}/creators/ {id}/ links/ {linkId}Retrieve a link
Parameters
id- Creator id.
linkId- Link id.
Request
curl https://linkswith.me/api/v1/creators/{id}/links/{linkId} \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}/links/{linkId}', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators/{id}/links/{linkId}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"id": 1024,
"user_id": 42,
"label": "My OnlyFans",
"url": "https://onlyfans.com/jane",
"icon": "onlyfans",
"position": 1,
"active": 1,
"created_at": "2026-05-10T18:14:33Z"
}/creators/ {id}/ links/ {linkId}Update a link
Parameters
id- Creator id.
linkId- Link id.
Body
label- New label.
url- New absolute http:// or https:// URL (same rules as create).
icon- New service key.
position- Sort position.
active- Show/hide.
Request
curl -X PATCH https://linkswith.me/api/v1/creators/{id}/links/{linkId} \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"label": "My OnlyFans",
"url": "https://onlyfans.com/jane",
"icon": "onlyfans",
"position": 1,
"active": true
}'const res = await fetch('https://linkswith.me/api/v1/creators/{id}/links/{linkId}', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"label": "My OnlyFans",
"url": "https://onlyfans.com/jane",
"icon": "onlyfans",
"position": 1,
"active": true
}),
});
const data = await res.json();import os, httpx
r = httpx.patch(
"https://linkswith.me/api/v1/creators/{id}/links/{linkId}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"label": "My OnlyFans",
"url": "https://onlyfans.com/jane",
"icon": "onlyfans",
"position": 1,
"active": True
},
)
data = r.json()Response
{
"id": 1024,
"user_id": 42,
"label": "🔥 My OnlyFans",
"url": "https://onlyfans.com/jane",
"icon": "onlyfans",
"position": 1,
"active": 1,
"created_at": "2026-05-10T18:14:33Z"
}/creators/ {id}/ links/ {linkId}Delete a link
Parameters
id- Creator id.
linkId- Link id.
Request
curl -X DELETE https://linkswith.me/api/v1/creators/{id}/links/{linkId} \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}/links/{linkId}', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.delete(
"https://linkswith.me/api/v1/creators/{id}/links/{linkId}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{ "deleted": true }/creators/ {id}/ links/ reorderReorder all links atomically
Parameters
id- Creator id.
Body
order- Ordered array of link IDs (or pass the array directly as the body).
Request
curl -X POST https://linkswith.me/api/v1/creators/{id}/links/reorder \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"order": 1
}'const res = await fetch('https://linkswith.me/api/v1/creators/{id}/links/reorder', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"order": 1
}),
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/creators/{id}/links/reorder",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"order": 1
},
)
data = r.json()Response
{
"data": [
{ "id": 41, "user_id": 42, "label": "Instagram", "url": "https://instagram.com/jane", "icon": "instagram", "position": 1, "active": 1, "created_at": "2026-04-02T09:18:01Z" },
{ "id": 40, "user_id": 42, "label": "My OnlyFans", "url": "https://onlyfans.com/jane", "icon": "onlyfans", "position": 2, "active": 1, "created_at": "2026-04-02T09:17:33Z" }
]
}Customization
One PATCH endpoint covers everything: theme presets, accent colour, blur, every live widget toggle, age gate, dominant CTA pick, countdown, and hero media. See the field reference below for the full list.
/creators/ {id}/ customizationGet a creator's full customization
Parameters
id- Creator id.
Request
curl https://linkswith.me/api/v1/creators/{id}/customization \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}/customization', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators/{id}/customization",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"theme_bg": "custom",
"theme_card_bg": "glass-white",
"theme_text_color": "dark",
"custom_bg_url": "data:image/jpeg;base64,…",
"bg_blur": 11,
"card_opacity": 88,
"card_radius": 24,
"link_style": "rounded",
"accent_color": "#61cdff",
"font_family": "Outfit",
"hide_branding": true,
"social_icons_style": "filled",
"widget_replies_in": true,
"widget_replies_min": 1,
"widget_replies_max": 3,
"widget_replies_unit": "min",
"widget_distance": false,
"widget_distance_unit": "km",
"widget_distance_min": 3,
"widget_distance_max": 25,
"widget_online": true,
"widget_online_label": "Online now",
"widget_last_seen": false,
"widget_last_seen_at": null,
"widget_viewing_now": false,
"widget_local_time": false,
"widget_creator_timezone": "Europe/Berlin",
"widget_subscriber_count": false,
"widget_subscriber_count_value": 0,
"widget_subscriber_count_label": "subscribers",
"age_gate_enabled": true,
"age_gate_headline": "Adult content",
"age_gate_subtext": "You must be 18 or older to enter.",
"cta_style": "single",
"cta_link_id": 40,
"sticky_cta": false,
"countdown_enabled": false,
"countdown_label": "Offer ends in",
"countdown_ends_at": null,
"hero_media_url": "",
"hero_media_type": "image"
}/creators/ {id}/ customizationUpdate customization
- Enable the click-time 18+ gate:
{ "age_gate_enabled": true } - Promote one link as the dominant CTA:
{ "cta_style": "single", "cta_link_id": 40 } - Add a 24h countdown:
{ "countdown_enabled": true, "countdown_ends_at": "2026-12-31T23:59Z" }
Parameters
id- Creator id.
Body
cta_style- "menu" or "single".
cta_link_id- When cta_style="single", which link is the dominant CTA.
age_gate_enabled- Click-time 18+ confirmation modal on adult-platform links.
accent_color- Hex colour like "#61cdff".
bg_blur- Background blur in px (0-30).
Request
curl -X PATCH https://linkswith.me/api/v1/creators/{id}/customization \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"cta_style": "single",
"cta_link_id": 40,
"age_gate_enabled": true,
"accent_color": "#61cdff",
"bg_blur": 11
}'const res = await fetch('https://linkswith.me/api/v1/creators/{id}/customization', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"cta_style": "single",
"cta_link_id": 40,
"age_gate_enabled": true,
"accent_color": "#61cdff",
"bg_blur": 11
}),
});
const data = await res.json();import os, httpx
r = httpx.patch(
"https://linkswith.me/api/v1/creators/{id}/customization",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"cta_style": "single",
"cta_link_id": 40,
"age_gate_enabled": True,
"accent_color": "#61cdff",
"bg_blur": 11
},
)
data = r.json()Response
{
"cta_style": "single",
"cta_link_id": 40,
"age_gate_enabled": true,
"accent_color": "#61cdff",
"bg_blur": 11,
"...": "(plus every other field unchanged)"
}Customization field reference
theme_bgstring- Preset key (gradient-brand, gradient-sunset, gradient-dark, …) or "custom" to use custom_bg_url.
theme_card_bgenum- glass-white | glass-dark | solid-white | solid-dark | transparent.
theme_text_colorenum- auto | light | dark.
custom_bg_urlstring- External URL or data: URL when theme_bg = "custom".
bg_blurinteger (0-30)- Backdrop-filter blur on the bg.
card_opacityinteger (0-100)- Opacity of the central card.
card_radiusinteger (0-48)- Card border-radius in px.
link_styleenum- rounded | pill | square | outline | filled | shadow.
accent_colorhex- Primary accent. Used on link borders, sliders, fallback avatar.
font_familystring- Google Font name.
hide_brandingboolean- Hide the each.bio footer link.
social_icons_styleenum- filled | outline | minimal | none.
widget_replies_inboolean- Show "Replies in X-Y min/h" chip.
widget_replies_min/maxinteger- Range bounds.
widget_replies_unitenum- min | hour.
widget_distanceboolean- CTA chip "{visitor city} · N km away" — IP-derived city + plausible km.
widget_distance_min/maxinteger- Distance range (deterministic per IP, picked once).
widget_distance_unitenum- km | mi.
widget_onlineboolean- Green "Online now" pulse chip.
widget_online_labelstring- Custom label, defaults to "Online now".
widget_last_seenboolean- Show "Active 5 min ago" relative chip when widget_last_seen_at is set.
widget_last_seen_attimestamp- When the creator was last seen.
widget_viewing_nowboolean- Polls "X others viewing" (last 5 min, distinct IPs).
widget_local_timeboolean- Show creator's local time, ticks every 15 s.
widget_creator_timezonestring- IANA timezone, e.g. "Europe/Berlin".
widget_subscriber_countboolean- Display "{N} subscribers" social-proof chip.
widget_subscriber_count_valueinteger- The number to show.
age_gate_enabledboolean- Click-time 18+ confirmation modal on onlyfans/fansly links.
age_gate_headlinestring- Modal headline copy.
age_gate_subtextstring- Modal body copy.
cta_styleenum- menu | single — single promotes one link as the dominant CTA.
cta_link_idinteger- Which link is the dominant CTA (only when cta_style="single").
sticky_ctaboolean- Float the dominant CTA in the thumb-zone at the bottom.
countdown_enabledboolean- Show a live countdown banner above the card.
countdown_labelstring- Banner copy.
countdown_ends_attimestamp- Target end time.
hero_media_urlstring- Image or short video above the card.
hero_media_typeenum- image | video.
Analytics
Aggregates and event streams. Period is configurable via ?period= with allowed values 24h, 7d, 30d (default), 90d, or all.
/creators/ {id}/ analytics/ summaryAggregate summary
Parameters
id- Creator id.
period- Time window. Default 30d.
Request
curl https://linkswith.me/api/v1/creators/{id}/analytics/summary \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}/analytics/summary', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators/{id}/analytics/summary",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"period": { "start": "2026-04-10T22:14:33Z", "end": "2026-05-10T22:14:33Z" },
"page_views": 12456,
"link_clicks": 1205,
"click_through_rate": 0.0967,
"today_views": 432,
"timeseries": [
{ "date": "2026-05-08", "views": 401, "clicks": 33 },
{ "date": "2026-05-09", "views": 412, "clicks": 38 },
{ "date": "2026-05-10", "views": 432, "clicks": 41 }
]
}/creators/ {id}/ analytics/ linksPer-link click counts
Parameters
id- Creator id.
period- Time window. Default 30d.
Request
curl https://linkswith.me/api/v1/creators/{id}/analytics/links \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}/analytics/links', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators/{id}/analytics/links",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"data": [
{ "id": 40, "label": "My OnlyFans", "clicks": 874 },
{ "id": 41, "label": "Instagram", "clicks": 231 }
]
}/creators/ {id}/ analytics/ geoCountry + city breakdown
Parameters
id- Creator id.
period- Time window. Default 30d.
Request
curl https://linkswith.me/api/v1/creators/{id}/analytics/geo \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}/analytics/geo', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators/{id}/analytics/geo",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"countries": [
{ "country": "Germany", "country_code": "DE", "count": 4317 },
{ "country": "United States", "country_code": "US", "count": 2811 }
],
"cities": [
{ "city": "Berlin", "country_code": "DE", "count": 871 },
{ "city": "Los Angeles", "country_code": "US", "count": 412 }
]
}/creators/ {id}/ analytics/ devicesDevice + traffic-source breakdown
Parameters
id- Creator id.
period- Time window. Default 30d.
Request
curl https://linkswith.me/api/v1/creators/{id}/analytics/devices \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}/analytics/devices', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators/{id}/analytics/devices",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"platforms": [
{ "platform": "ios", "count": 7723 },
{ "platform": "android", "count": 3892 },
{ "platform": "desktop", "count": 841 }
],
"sources": [
{ "source": "instagram", "count": 5481 },
{ "source": "tiktok", "count": 3127 },
{ "source": "direct", "count": 1844 }
]
}/creators/ {id}/ analytics/ activityLive activity feed
Parameters
id- Creator id.
limit- Max events to return (default 25, max 100).
Request
curl https://linkswith.me/api/v1/creators/{id}/analytics/activity \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/creators/{id}/analytics/activity', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/creators/{id}/analytics/activity",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"data": [
{
"type": "link_click",
"id": "c8214",
"ts": "2026-05-10T22:13:09Z",
"country": "Germany",
"country_code": "DE",
"city": "Berlin",
"source": "instagram",
"label": "My OnlyFans"
},
{
"type": "page_view",
"id": "v40121",
"ts": "2026-05-10T22:12:55Z",
"country": "United States",
"country_code": "US",
"city": "Los Angeles",
"source": "tiktok",
"platform": "ios"
}
]
}Bulk operations
Each item is attempted independently — failures don't abort the rest. The response shape is always{ applied|created|deleted: [...], errors: [{id|slug, code, detail}] }. Hard caps: 200 IDs per call for customization / delete, 100 creators per create. An item that fails for an unexpected server reason gets code: "internal" with the detail "Something went wrong (request req_…)"; quote that request id to support. All bulk endpoints require admin role for creators ops, write for customization.
/bulk/ customizationApply one customization patch to many creators
Body
creator_ids- Up to 200 creator IDs the key has access to.
patch- Any subset of customization fields (see /creators/{id}/customization).
Request
curl -X POST https://linkswith.me/api/v1/bulk/customization \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"creator_ids": 1,
"patch": ""
}'const res = await fetch('https://linkswith.me/api/v1/bulk/customization', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"creator_ids": 1,
"patch": ""
}),
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/bulk/customization",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"creator_ids": 1,
"patch": ""
},
)
data = r.json()Response
{
"applied": [42, 43, 44, 46],
"errors": [
{ "id": 45, "code": "forbidden", "detail": "API key is not authorized for creator 45." }
],
"applied_count": 4,
"error_count": 1
}/bulk/ creatorsCreate many managed creators in one call
Body
creators- Array of
{ slug, display_name, bio?, primary_host? }objects. Each entry may set its own primary_host or leave it for the server to pick.
Request
curl -X POST https://linkswith.me/api/v1/bulk/creators \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"creators": [40,41,42]
}'const res = await fetch('https://linkswith.me/api/v1/bulk/creators', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"creators": [40,41,42]
}),
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/bulk/creators",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"creators": [40,41,42]
},
)
data = r.json()Response
{
"created": [
{ "id": 110, "slug": "jane", "display_name": "Jane", "bio": "", "avatar_url": null, "account_type": "managed", "managed_by": 1, "primary_host": "backup2.example", "created_at": "..." },
{ "id": 111, "slug": "kim", "display_name": "Kim", "bio": "", "avatar_url": null, "account_type": "managed", "managed_by": 1, "primary_host": "backup3.example", "created_at": "..." }
],
"errors": [
{ "slug": "jane", "code": "conflict", "detail": "Slug \"jane\" is already taken." }
],
"created_count": 2,
"error_count": 1
}/bulk/ creators.deleteDelete many managed creators in one call
Body
creator_ids- Up to 200 IDs.
Request
curl -X POST https://linkswith.me/api/v1/bulk/creators.delete \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"creator_ids": 1
}'const res = await fetch('https://linkswith.me/api/v1/bulk/creators.delete', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"creator_ids": 1
}),
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/bulk/creators.delete",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"creator_ids": 1
},
)
data = r.json()Response
{
"deleted": [110, 111],
"errors": [
{ "id": 999, "code": "not_found", "detail": "Creator 999 not found." }
],
"deleted_count": 2,
"error_count": 1
}Uploads
Use this when you'd rather not stuff base64 into your customization payloads. The endpoint stores the file content-addressed (sha256-prefix filename), serves it from https://linkswith.me/uploads/<hash>.<ext>, and returns the URL. Re-uploading the same bytes is idempotent — same URL, no duplicate stored. Then PATCH that URL onto avatar_url / custom_bg_url / hero_media_url.
/uploadsUpload an image or short video
file field. Max 10 MB. Allowed content types: image/jpeg, image/png, image/webp, image/gif, video/mp4, video/webm. Requires write role.Body
file- The image or video. Send as multipart, NOT JSON.
Request
curl -X POST https://linkswith.me/api/v1/uploads \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"file": ""
}'const res = await fetch('https://linkswith.me/api/v1/uploads', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"file": ""
}),
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/uploads",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"file": ""
},
)
data = r.json()Response
{
"url": "https://linkswith.me/uploads/4f8d6e2a91c3b75e6f8d2a01b9c47e83.jpg",
"bytes": 184231,
"content_type": "image/jpeg",
"sha256": "4f8d6e2a91c3b75e6f8d2a01b9c47e83a14b9d6e2f1c5b8e9a47d2c8b6e1f5a3",
"filename": "4f8d6e2a91c3b75e6f8d2a01b9c47e83.jpg"
}{
"type": "https://docs.linkswith.me/errors/payload_too_large",
"title": "Payload Too Large",
"status": 413,
"detail": "File is 15728640 bytes; max is 10485760 (10 MB).",
"code": "payload_too_large",
"param": "file",
"request_id": "req_a9f8b7c6d5e4"
}Webhooks
Subscribe to events instead of polling. Each delivery is signed with HMAC-SHA256 against a secret you receive once at endpoint creation. Failed deliveries retry on an exponential schedule (30s → 5m → 30m → 3h → 12h → 24h, then give up). All webhook endpoints require the admin role and a key with scope "all"; a key limited to specific creators gets 403 forbidden.
Endpoint URL rules. In production the URL must use https:// on the default port (443) and must not contain a username or password. Its host must resolve to a public internet address. localhost, private, loopback, link-local (including cloud metadata at 169.254.169.254), CGNAT, multicast, documentation and other reserved ranges (IPv4 and IPv6), this server's own addresses, and hosts that don't resolve are rejected with 400 invalid_request. The check runs on create / update and again before every delivery; if the host now resolves to a blocked address, that attempt fails and is retried on the normal schedule.
Event types
*- Wildcard. Subscribe to everything.
creator.created- Managed creator created via POST /creators or /bulk/creators.
creator.updated- Profile fields (name/bio/avatar) updated via PATCH /creators/{id}. Domain moves by each.bio don't send it; GET the creator to see primary_host.
creator.deleted- Managed creator removed.
link.created- Link added to a creator.
link.updated- Link edited (label, url, icon, position, active).
link.deleted- Link removed.
link.clicked- High volume. Fires on every /go redirect. Off by default — opt in explicitly.
page.viewed- High volume. Fires on every /[slug] render. Off by default — opt in explicitly.
page.geo_blocked- A visitor (page view or click) was rejected because their country matched the creator's geo-block list.
customization.updated- Any field in /customization changed (theme, widgets, CTA, age gate, etc.).
api_key.created- Key created.
api_key.revoked- Key revoked or rotated.
Delivery envelope
{
"id": "evt_a1b2c3d4e5f6",
"type": "customization.updated",
"created": "2026-05-10T22:13:09Z",
"api_version": "v1",
"data": {
"object": {
"creator_id": 42,
"cta_style": "single",
"cta_link_id": 40,
"/* ...rest of the customization payload... */": ""
}
}
}Headers on every delivery: LWM-Signature: t=<unix>,v1=<hex hmac-sha256>, LWM-Event-Id, LWM-Event-Type, User-Agent: linkswith-webhooks/1. Reply with any 2xx within 8 seconds to acknowledge. Anything else (including timeout) triggers a retry. Redirects are not followed: a 3xx counts as a failed delivery and is retried.
/webhook_endpointsList webhook endpoints
Request
curl https://linkswith.me/api/v1/webhook_endpoints \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/webhook_endpoints', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/webhook_endpoints",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"data": [
{
"id": 12,
"url": "https://everyfeed.app/integrations/linkswith/webhook",
"description": "EveryFeed creator-row sync",
"events": ["creator.updated", "customization.updated", "link.created", "link.updated", "link.deleted"],
"secret_prefix": "whsec_a1b2c3d4…",
"created_at": "2026-05-10T22:01:14Z",
"revoked": false
}
]
}/webhook_endpointsRegister a new webhook endpoint
whsec_). Store it; you'll use it to verify HMAC signatures on every delivery. Webhooks are part of the Agency plan: on another plan this returns 403 plan_limit_exceeded (limit_type 'webhooks'); existing endpoints keep receiving events.Body
url- https:// URL we will POST events to (http:// and non-default ports only in local dev). No username or password; the host must resolve to a public address (see the URL rules above). Otherwise 400 invalid_request.
description- Free-text label to identify this endpoint in the dashboard.
events- Event-type filter. Default
["*"](everything). Use exact event-type strings to opt in selectively.
Request
curl -X POST https://linkswith.me/api/v1/webhook_endpoints \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://onlyfans.com/jane",
"description": "",
"events": [40,41,42]
}'const res = await fetch('https://linkswith.me/api/v1/webhook_endpoints', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://onlyfans.com/jane",
"description": "",
"events": [40,41,42]
}),
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/webhook_endpoints",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"url": "https://onlyfans.com/jane",
"description": "",
"events": [40,41,42]
},
)
data = r.json()Response
{
"id": 12,
"url": "https://everyfeed.app/integrations/linkswith/webhook",
"description": "EveryFeed creator-row sync",
"events": ["creator.updated", "customization.updated"],
"secret_prefix": "whsec_a1b2c3d4…",
"created_at": "2026-05-10T22:01:14Z",
"revoked": false,
"secret": "whsec_a1b2c3d4e5f67890abcdef1234567890fedcba9876543210abcdef1234567890ab"
}/webhook_endpoints/ {id}Retrieve an endpoint
Parameters
id- Endpoint id.
Request
curl https://linkswith.me/api/v1/webhook_endpoints/{id} \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/webhook_endpoints/{id}', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/webhook_endpoints/{id}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{ "id": 12, "url": "...", "description": "...", "events": ["*"], "secret_prefix": "whsec_…", "created_at": "...", "revoked": false }/webhook_endpoints/ {id}Update an endpoint
Parameters
id- Endpoint id.
Body
url- New target URL. Same rules as create.
description- New label.
events- New event-type filter.
Request
curl -X PATCH https://linkswith.me/api/v1/webhook_endpoints/{id} \
-H "Authorization: Bearer lwm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://onlyfans.com/jane",
"description": "",
"events": [40,41,42]
}'const res = await fetch('https://linkswith.me/api/v1/webhook_endpoints/{id}', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://onlyfans.com/jane",
"description": "",
"events": [40,41,42]
}),
});
const data = await res.json();import os, httpx
r = httpx.patch(
"https://linkswith.me/api/v1/webhook_endpoints/{id}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
json={
"url": "https://onlyfans.com/jane",
"description": "",
"events": [40,41,42]
},
)
data = r.json()Response
{ "id": 12, "url": "...", "events": ["creator.updated"], "...": "" }/webhook_endpoints/ {id}Revoke an endpoint
Parameters
id- Endpoint id.
Request
curl -X DELETE https://linkswith.me/api/v1/webhook_endpoints/{id} \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/webhook_endpoints/{id}', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.delete(
"https://linkswith.me/api/v1/webhook_endpoints/{id}",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{ "revoked": true }/webhook_endpoints/ {id}/ rotate_secretRotate the signing secret
Parameters
id- Endpoint id.
Request
curl -X POST https://linkswith.me/api/v1/webhook_endpoints/{id}/rotate_secret \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/webhook_endpoints/{id}/rotate_secret', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.post(
"https://linkswith.me/api/v1/webhook_endpoints/{id}/rotate_secret",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{ "id": 12, "secret": "whsec_new…", "...": "" }/webhook_endpoints/ {id}/ deliveriesList recent deliveries
Parameters
id- Endpoint id.
limit- Default 50, max 200.
Request
curl https://linkswith.me/api/v1/webhook_endpoints/{id}/deliveries \
-H "Authorization: Bearer lwm_sk_live_…"const res = await fetch('https://linkswith.me/api/v1/webhook_endpoints/{id}/deliveries', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LWM_API_KEY}`,
},
});
const data = await res.json();import os, httpx
r = httpx.get(
"https://linkswith.me/api/v1/webhook_endpoints/{id}/deliveries",
headers={"Authorization": f"Bearer {os.environ['LWM_API_KEY']}"},
)
data = r.json()Response
{
"data": [
{
"id": 8412,
"endpoint_id": 12,
"event_type": "customization.updated",
"event_id": "evt_a1b2c3d4e5f6",
"status_code": 200,
"attempts": 1,
"delivered_at": "2026-05-10T22:13:10Z",
"gave_up_at": null,
"next_retry_at": null,
"created_at": "2026-05-10T22:13:09Z"
},
{
"id": 8411,
"endpoint_id": 12,
"event_type": "link.created",
"event_id": "evt_b2c3d4e5f6a1",
"status_code": 502,
"attempts": 3,
"delivered_at": null,
"gave_up_at": null,
"next_retry_at": "2026-05-10T22:43:09Z",
"created_at": "2026-05-10T22:13:09Z"
}
]
}Verify signatures
Compute HMAC-SHA256 over <timestamp>.<raw_body> using your endpoint's secret (with the whsec_ prefix stripped). Reject deliveries whose t is more than 5 minutes old (replay protection) or whose v1 doesn't match in constant time.
import { createHmac, timingSafeEqual } from 'crypto';
// secret comes from POST /v1/webhook_endpoints (strip the whsec_ prefix)
function verify(rawBody, header, secret) {
const m = /t=(\d+),v1=([0-9a-f]+)/.exec(header || '');
if (!m) return false;
const t = Number(m[1]);
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 min replay window
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(m[2]); const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
// Express:
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verify(req.body.toString('utf8'),
req.header('LWM-Signature'),
process.env.LWM_WEBHOOK_SECRET);
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event ...
res.status(200).end();
}
);import hmac, hashlib, time, re
def verify(raw_body: bytes, header: str, secret: str) -> bool:
m = re.match(r'^t=(\d+),v1=([0-9a-f]+)$', header or '')
if not m: return False
t = int(m.group(1))
if abs(time.time() - t) > 300: return False
expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(m.group(2), expected)
# FastAPI:
@app.post('/webhook')
async def webhook(request):
raw = await request.body()
if not verify(raw, request.headers.get('LWM-Signature', ''), os.environ['LWM_WEBHOOK_SECRET']):
return Response(status_code=400)
event = json.loads(raw)
# ... handle event ...
return Response(status_code=200)# Quick replay check on the command line
TS=1747008789
BODY='{"id":"evt_test","type":"creator.updated"}'
SECRET='your-secret-without-whsec-prefix'
echo -n "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET"
# Compare to v1=… in the LWM-Signature header you received