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/v1

Auth

Bearer lwm_sk_live_…

Format

application/json

Quick start

  1. Create an API key in /dashboard/api. The plaintext is shown once on creation — store it immediately.
  2. Send it on every request as Authorization: Bearer ….
  3. 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"}'

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.
write
read + 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.

400 Bad request
{
  "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"
}
HTTPCodeMeaning
400invalid_requestBad JSON, missing required field, malformed value.
401unauthorizedMissing / malformed / unknown Bearer token.
403forbiddenWrong role, or creator out of the key's scope. Also "This account is suspended."
403plan_limit_exceededThe 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.
404not_foundResource doesn't exist or isn't yours.
409conflictSlug already taken, key name collision.
422unprocessableValidation passed shape but failed semantics.
500internalServer 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.

403 Forbidden
{
  "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-Limit
integer
Sustained rate per minute. Currently 60 for every key.
X-RateLimit-Remaining
integer
Tokens left in the bucket right now.
X-RateLimit-Reset
integer
Seconds until the bucket is full again.
Retry-After
integer
Only on 429. Seconds to wait before retrying.
X-Request-Id
string
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.

200 OK
{
  "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.me or one of the backup domains — 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. 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, an icon service key (onlyfans, fansly, instagram, …), a position, and an active flag.
  • 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

GET/me

Who am I?

Returns the user that owns the API key, plus metadata about the key itself (its role and scope). Use this to verify a key works. It also returns the account's 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

includequery · string
Optional. usage adds usage.pages (pages the account has, its own page included).

Request

curl https://linkswith.me/api/v1/me \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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"]
}
401 UnauthorizedBad/missing key
{
  "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.

GET/api_keys

List API keys

All keys owned by the authenticated account, including revoked ones. Keys whose scope is not within the calling key's scope are left out.

Request

curl https://linkswith.me/api/v1/api_keys \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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
    }
  ]
}
POST/api_keys

Create a new API key

The plaintext secret is included in the response once and never again. If you lose it, rotate the key. API keys are part of the Agency plan: on another plan this returns 403 plan_limit_exceeded (limit_type: "api").

Body

namestring · required
Human-readable label, shown in the dashboard.
roleenum
"read", "write", or "admin". Defaults to "admin".
scopestring | integer[]
"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"
}'

Response

201 Createdplaintext returned only here
{
  "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"
}
GET/api_keys/{id}

Retrieve a key

Fetch a key's metadata. The secret is never returned again after creation.

Parameters

idpath · integer · required
API key id.

Request

curl https://linkswith.me/api/v1/api_keys/{id} \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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
}
PATCH/api_keys/{id}

Update a key

Rename a key or change its scope. Role is immutable — issue a new key for that.

Parameters

idpath · integer · required
API key id.

Body

namestring
New name.
scopestring | integer[]
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"
}'

Response

200 OK
{
  "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
}
DELETE/api_keys/{id}

Revoke a key

Immediate. Any in-flight request using this key will start returning 401.

Parameters

idpath · integer · required
API key id.

Request

curl -X DELETE https://linkswith.me/api/v1/api_keys/{id} \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{ "revoked": true }
POST/api_keys/{id}/rotate

Rotate a key

Revokes the current secret and issues a new one with the same name, role and scope, copied verbatim from the stored key. The new plaintext is in the response.

Parameters

idpath · integer · required
API key id to rotate.

Request

curl -X POST https://linkswith.me/api/v1/api_keys/{id}/rotate \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

201 Creatednew plaintext returned only here
{
  "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.

GET/creators

List creators

Every creator the API key can see. Individuals: yourself. Agencies: yourself + every managed creator (filtered by the key's scope). Cursor-paginated.

Parameters

limitquery · integer
Page size. Default 100, max 500.
cursorquery · string
Cursor returned as next_cursor from the previous page. Omit on first call.

Request

curl https://linkswith.me/api/v1/creators \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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
}
POST/creators

Create a managed creator

Agency accounts only. Creates a new managed profile under your agency. The 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

slugstring · required
3-30 chars. Lowercase letters, digits, _ or -. Cannot start or end with _/-. Cannot be changed after creation.
display_namestring · required
Visible name on the public page.
biostring
Short tagline shown under the avatar.
primary_hoststring
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": ""
}'

Response

201 Created
{
  "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"
}
409 ConflictSlug taken
{
  "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"
}
GET/creators/{id}

Retrieve a creator

Fetch a creator's basic profile. Customization lives at /creators/{id}/customization.

Parameters

idpath · integer · required
Creator id.

Request

curl https://linkswith.me/api/v1/creators/{id} \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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"
}
PATCH/creators/{id}

Update a creator's profile

Edit the basic profile (display name, bio, avatar). Customization (theme, widgets, CTA) lives at /creators/{id}/customization.

Parameters

idpath · integer · required
Creator id.

Body

display_namestring
New display name.
biostring
New bio.
avatar_urlstring
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"
}'

Response

200 OK
{
  "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"
}
DELETE/creators/{id}

Delete a managed creator

Permanently removes the creator and every link they own. Cannot be used to delete your own account.

Parameters

idpath · integer · required
Creator id.

Request

curl -X DELETE https://linkswith.me/api/v1/creators/{id} \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{ "deleted": true }
GET/slug-availability

Check if a slug is available

UX helper for typeahead in 'create creator' forms. Validates format, checks the reserved-word list (which includes the app's own routes, such as legal), and queries the DB. Cheap to call — does not log a page view.

Parameters

slugquery · string · required
The slug to check. Lowercased + trimmed before validation.

Request

curl https://linkswith.me/api/v1/slug-availability \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OKavailable
{ "slug": "jane", "available": true }
200 OKtaken or invalid
{
  "slug": "jane",
  "available": false,
  "reason": "slug is already taken"
}

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

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.

GET/creators/{id}/customization

Get a creator's full customization

Returns every customization field with its current value.

Parameters

idpath · integer · required
Creator id.

Request

curl https://linkswith.me/api/v1/creators/{id}/customization \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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"
}
PATCH/creators/{id}/customization

Update customization

PATCH any combination. Fields you omit are unchanged. Common patterns:
  • 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

idpath · integer · required
Creator id.

Body

cta_styleenum
"menu" or "single".
cta_link_idinteger
When cta_style="single", which link is the dominant CTA.
age_gate_enabledboolean
Click-time 18+ confirmation modal on adult-platform links.
accent_colorstring
Hex colour like "#61cdff".
bg_blurinteger
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
}'

Response

200 OK
{
  "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_bg
string
Preset key (gradient-brand, gradient-sunset, gradient-dark, …) or "custom" to use custom_bg_url.
theme_card_bg
enum
glass-white | glass-dark | solid-white | solid-dark | transparent.
theme_text_color
enum
auto | light | dark.
custom_bg_url
string
External URL or data: URL when theme_bg = "custom".
bg_blur
integer (0-30)
Backdrop-filter blur on the bg.
card_opacity
integer (0-100)
Opacity of the central card.
card_radius
integer (0-48)
Card border-radius in px.
link_style
enum
rounded | pill | square | outline | filled | shadow.
accent_color
hex
Primary accent. Used on link borders, sliders, fallback avatar.
font_family
string
Google Font name.
hide_branding
boolean
Hide the each.bio footer link.
social_icons_style
enum
filled | outline | minimal | none.
widget_replies_in
boolean
Show "Replies in X-Y min/h" chip.
widget_replies_min/max
integer
Range bounds.
widget_replies_unit
enum
min | hour.
widget_distance
boolean
CTA chip "{visitor city} · N km away" — IP-derived city + plausible km.
widget_distance_min/max
integer
Distance range (deterministic per IP, picked once).
widget_distance_unit
enum
km | mi.
widget_online
boolean
Green "Online now" pulse chip.
widget_online_label
string
Custom label, defaults to "Online now".
widget_last_seen
boolean
Show "Active 5 min ago" relative chip when widget_last_seen_at is set.
widget_last_seen_at
timestamp
When the creator was last seen.
widget_viewing_now
boolean
Polls "X others viewing" (last 5 min, distinct IPs).
widget_local_time
boolean
Show creator's local time, ticks every 15 s.
widget_creator_timezone
string
IANA timezone, e.g. "Europe/Berlin".
widget_subscriber_count
boolean
Display "{N} subscribers" social-proof chip.
widget_subscriber_count_value
integer
The number to show.
age_gate_enabled
boolean
Click-time 18+ confirmation modal on onlyfans/fansly links.
age_gate_headline
string
Modal headline copy.
age_gate_subtext
string
Modal body copy.
cta_style
enum
menu | single — single promotes one link as the dominant CTA.
cta_link_id
integer
Which link is the dominant CTA (only when cta_style="single").
sticky_cta
boolean
Float the dominant CTA in the thumb-zone at the bottom.
countdown_enabled
boolean
Show a live countdown banner above the card.
countdown_label
string
Banner copy.
countdown_ends_at
timestamp
Target end time.
hero_media_url
string
Image or short video above the card.
hero_media_type
enum
image | video.

Analytics

Aggregates and event streams. Period is configurable via ?period= with allowed values 24h, 7d, 30d (default), 90d, or all.

GET/creators/{id}/analytics/summary

Aggregate summary

Page views, link clicks, click-through rate, today's views, and a per-day timeseries for the period.

Parameters

idpath · integer · required
Creator id.
periodquery · enum
Time window. Default 30d.

Request

curl https://linkswith.me/api/v1/creators/{id}/analytics/summary \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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 }
  ]
}
GET/creators/{id}/analytics/geo

Country + city breakdown

Top countries and cities by page-view count.

Parameters

idpath · integer · required
Creator id.
periodquery · enum
Time window. Default 30d.

Request

curl https://linkswith.me/api/v1/creators/{id}/analytics/geo \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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 }
  ]
}
GET/creators/{id}/analytics/devices

Device + traffic-source breakdown

Platform mix (iOS / Android / desktop) and traffic source mix (instagram / tiktok / etc.).

Parameters

idpath · integer · required
Creator id.
periodquery · enum
Time window. Default 30d.

Request

curl https://linkswith.me/api/v1/creators/{id}/analytics/devices \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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 }
  ]
}
GET/creators/{id}/analytics/activity

Live activity feed

Combined stream of clicks + page views, newest first. Useful for live dashboards or webhook fallbacks.

Parameters

idpath · integer · required
Creator id.
limitquery · integer
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_…"

Response

200 OK
{
  "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.

POST/bulk/customization

Apply one customization patch to many creators

The same partial Customization payload is PATCHed onto every creator in the list. Each is independently scope-checked. Use for 'apply weekend campaign to these 40 creators'.

Body

creator_idsinteger[] · required
Up to 200 creator IDs the key has access to.
patchobject · required
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": ""
}'

Response

200 OK
{
  "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
}
POST/bulk/creators

Create many managed creators in one call

Up to 100 creators per call. Each one goes through the same validation as POST /creators, including the weighted primary_host assignment when omitted and the account's page limit: an item past the limit gets code plan_limit_exceeded in errors[] (the call still answers 201).

Body

creatorsarray · required
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]
}'

Response

201 Created
{
  "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
}
POST/bulk/creators.delete

Delete many managed creators in one call

POST + .delete suffix because DELETE-with-body is unreliable across clients. Each ID is independently scope-checked.

Body

creator_idsinteger[] · required
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
}'

Response

200 OK
{
  "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.

POST/uploads

Upload an image or short video

multipart/form-data with a file field. Max 10 MB. Allowed content types: image/jpeg, image/png, image/webp, image/gif, video/mp4, video/webm. Requires write role.

Body

filebinary · required
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": ""
}'

Response

201 Created
{
  "url": "https://linkswith.me/uploads/4f8d6e2a91c3b75e6f8d2a01b9c47e83.jpg",
  "bytes": 184231,
  "content_type": "image/jpeg",
  "sha256": "4f8d6e2a91c3b75e6f8d2a01b9c47e83a14b9d6e2f1c5b8e9a47d2c8b6e1f5a3",
  "filename": "4f8d6e2a91c3b75e6f8d2a01b9c47e83.jpg"
}
413 file > 10 MB
{
  "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

200 OKPOST body sent to your endpoint
{
  "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.

GET/webhook_endpoints

List webhook endpoints

All endpoints registered on this account, including revoked ones.

Request

curl https://linkswith.me/api/v1/webhook_endpoints \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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
    }
  ]
}
POST/webhook_endpoints

Register a new webhook endpoint

The full secret is returned ONCE in the response (prefixed with 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

urlstring · required
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.
descriptionstring
Free-text label to identify this endpoint in the dashboard.
eventsarray
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]
}'

Response

201 Createdsecret returned only here
{
  "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"
}
GET/webhook_endpoints/{id}

Retrieve an endpoint

Fetch a single endpoint. Secret is never returned again.

Parameters

idpath · integer · required
Endpoint id.

Request

curl https://linkswith.me/api/v1/webhook_endpoints/{id} \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{ "id": 12, "url": "...", "description": "...", "events": ["*"], "secret_prefix": "whsec_…", "created_at": "...", "revoked": false }
PATCH/webhook_endpoints/{id}

Update an endpoint

Change url, description, or event filter.

Parameters

idpath · integer · required
Endpoint id.

Body

urlstring
New target URL. Same rules as create.
descriptionstring
New label.
eventsarray
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]
}'

Response

200 OK
{ "id": 12, "url": "...", "events": ["creator.updated"], "...": "" }
DELETE/webhook_endpoints/{id}

Revoke an endpoint

Stops all future deliveries. In-flight deliveries finish then are marked failed if the endpoint was revoked between the dequeue and the POST.

Parameters

idpath · integer · required
Endpoint id.

Request

curl -X DELETE https://linkswith.me/api/v1/webhook_endpoints/{id} \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{ "revoked": true }
POST/webhook_endpoints/{id}/rotate_secret

Rotate the signing secret

Issues a new secret, invalidates the old one immediately. New secret is in the response (only here). Update your verifier ASAP — deliveries between rotation and your code update will fail signature verification.

Parameters

idpath · integer · required
Endpoint id.

Request

curl -X POST https://linkswith.me/api/v1/webhook_endpoints/{id}/rotate_secret \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

201 Creatednew secret
{ "id": 12, "secret": "whsec_new…", "...": "" }
GET/webhook_endpoints/{id}/deliveries

List recent deliveries

Last N delivery attempts for an endpoint. Useful for debugging: shows status code, attempt count, response body (first 500 chars), and whether we gave up.

Parameters

idpath · integer · required
Endpoint id.
limitquery · integer
Default 50, max 200.

Request

curl https://linkswith.me/api/v1/webhook_endpoints/{id}/deliveries \
  -H "Authorization: Bearer lwm_sk_live_…"

Response

200 OK
{
  "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();
  }
);