HTTP API
REST at https://api.linkdash.dev/v1 with a bearer key from the dashboard. Same operations as the CLI and MCP.
Base URL: https://api.linkdash.dev/v1
Auth: Authorization: Bearer ldash_... (create keys at Dashboard → API keys). Do not invent keys.
CLI reference: CLI. MCP: MCP. Pricing / quotas: Pricing. Discovery (no auth): GET https://api.linkdash.dev/v1. Plain markdown: api.md.
Public ids are slugs (deck, k3m9xq), not Convex _id. JSON field names match CLI stdout (id, url, kind, …).
Conventions
- JSON
Content-Type: application/jsonunless noted. - Create takes exactly one of
url,text, orstorageIdafterPOST /uploads, or anvariantsarray (see Variants). Mint returns{ "uploadUrl", "storageId" }. The host PUTs bytes touploadUrl. Small files may still use multipartfileonPOST /links. - Durations:
7d,24h,30m,1w, or an ISO date. Fields:expires/expiresAt,starts/startsAt. - Openings limit: optional positive integer
maxOpenings. The link pauses after that many successful openings. RaisemaxOpenings, orclearMaxOpenings, to serve it again. OmitmaxOpeningsfor unlimited. - If a slug exists on two of the owner’s domains, pass
?domain=/ JSONdomain. - Errors:
{ "error": "..." }plus optionalcode. Takenlida.shslugs return409withcode: "slug_taken". - Writes count toward the monthly event quota. List / get / stats / export / webhook list/get do not. Visit / download / play ingest still count.
Endpoints
| CLI | HTTP | MCP |
|---|---|---|
auth whoami |
GET /me |
whoami |
links list |
GET /links (?q=&tag=&kind=&status=) |
links_list |
links get <id> |
GET /links/:id (?domain=) |
links_get |
links create --url/--file/--text |
POST /links |
links_create |
links update <id> |
PATCH /links/:id |
links_update |
links delete <id> |
DELETE /links/:id |
links_delete |
links stats --last 24h |
GET /links/:id/stats?last=24h |
links_stats |
links export |
GET /links/export |
links_export |
links import |
POST /links/import |
links_import |
links variants list <id> |
GET /links/:id/variants |
variants_list |
links variants add <id> |
POST /links/:id/variants |
variants_add |
links variants update <id> <name> |
PATCH /links/:id/variants/:name |
variants_update |
links variants remove <id> <name> |
DELETE /links/:id/variants/:name |
variants_remove |
| (host PUT after mint) | POST /uploads |
files_upload_url |
DELETE /uploads/:storageId |
files_discard |
|
webhooks list/create/get/rotate/delete |
GET/POST /webhooks, GET/DELETE /webhooks/:id, POST /webhooks/:id/rotate |
webhooks_* |
domains list/add/connect/verify/remove |
GET/POST /domains, GET /domains/:host/connect, POST /domains/:host/verify, DELETE /domains/:host |
domains_* |
auth create, auth login, and auth logout are browser device flows. They are not Unkey routes.
GET /me
Signed-in account. { "userId": "...", "email": "...", "name": "..." }. Email and name are omitted when unknown.
Create a URL link
curl https://api.linkdash.dev/v1/links \
-H "Authorization: Bearer ldash_..." \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","slug":"welcome"}'
JSON body takes exactly one of url, text, or storageId, plus optional slug, name, tags, starts, expires, maxOpenings, password, domain, previewName, previewDescription, previewImageStorageId, sticky, forwardParams, and utm (URL links only). Shorthand creates one variant named main; top-level password, starts, expires, and maxOpenings are the link gate. Response: { "id", "url", "kind", "variants": [{ "name", "kind" }] }.
GET /links rows are { id, kind, status, variantStatuses, variantCount, name, tags, url, target, _id }; kind filter adds group, status filter adds mixed. GET /links/:id returns the full link with variants; content (destinationUrl / textContent / fileName) lives on each variant.
Upload a file
Mint an upload URL ({uploadUrl,storageId}), PUT the bytes from the host, then create the link in the same flow:
curl https://api.linkdash.dev/v1/uploads \
-H "Authorization: Bearer ldash_..." \
-H "Content-Type: application/json" \
-d '{"fileSize":12345}'
curl -X PUT -H "Content-Type: application/pdf" --data-binary @./deck.pdf "$uploadUrl"
curl https://api.linkdash.dev/v1/links \
-H "Authorization: Bearer ldash_..." \
-H "Content-Type: application/json" \
-d '{"storageId":"…","fileName":"deck.pdf","fileType":"application/pdf","fileSize":12345}'
Small files can still use multipart on POST /links:
curl https://api.linkdash.dev/v1/links \
-H "Authorization: Bearer ldash_..." \
-F "file=@./deck.pdf" \
-F "expires=7d" \
-F "slug=deck"
Optional multipart fields: slug, name, tags, starts / startsAt, password, expires, maxOpenings, domain, previewName, previewDescription, previewImage.
Update, stats, CSV
PATCH /links/:id is JSON or multipart. Replace file bytes with storageId after POST /uploads + host PUT, or multipart field file (single-variant links only). --status / { "status": "expired" } is rejected — use expires. { "status": "scheduled" } is rejected — use starts. Optional maxOpenings pauses the link after that many openings; clearMaxOpenings removes the limit. sticky (boolean) and restartSplit (boolean) control variant assignment; forwardParams (boolean) forwards the visitor’s query string, see Forward query parameters. Response: { "id", "url", "status", "variantStatuses" }.
GET /links/:id/stats?last=24h returns visits, downloads, plays, visitsInLast, countries, referrers, variants (per-variant totals), and visitors.
GET /links/export is CSV with columns slug,kind,name,variant,url,text,fileName,status,domain,tags,starts,expires,maxOpenings,weight,when,created, one row per variant. POST /links/import accepts raw CSV or multipart file; rows are grouped by slug + domain (first row creates the link with its variant, more rows add variants; existing links update fields, then update or add variants by name).
Variants
Every link is a container of one or more variants: content (url | file | text) plus its own gate (password, paused, starts, expires, maxOpenings), a weight, and optional visitor conditions when. The link owns slug, domain, name, tags, preview, its own gate (which wins over variant gates), sticky (default true), and splitVersion (bumped by restartSplit). Without variants a link has one variant named main. Names: lowercase letters, digits, internal hyphens, 1–40 chars, unique per link. Max 20 variants per link.
A visitor gets one variant: variants whose when matches form the pool (if none match, the unconditional variants do), then a weighted pick (sticky hash of visitor + link + splitVersion, else random). Crawlers get the first live unconditional variant. Link kind is group for several variants. Link status is the link gate when set, else the status all variants share, else mixed. variantStatuses counts { active, paused, expired, scheduled }.
Create several variants at once:
curl https://api.linkdash.dev/v1/links \
-H "Authorization: Bearer ldash_..." \
-H "Content-Type: application/json" \
-d '{"slug":"promo","variants":[{"name":"a","url":"https://example.com/a"},{"name":"b","url":"https://example.com/b","weight":3,"when":{"countries":["DE","AT"]}}]}'
Each variant: name (required), one of url | text | storageId (+ fileName, fileType, fileSize), password, starts, expires, maxOpenings, utm, weight, when. Multipart create stays shorthand-only.
| Route | Body | Response |
|---|---|---|
GET /links/:id/variants |
— | variants array |
POST /links/:id/variants |
JSON or multipart file: name (required), content, password, starts, expires, maxOpenings, utm, weight, when |
the variant |
PATCH /links/:id/variants/:name |
name (rename), url, text, storageId / file, password, clearPassword, starts, clearStarts, expires, clearExpires, maxOpenings, clearMaxOpenings, status (active | paused), weight, when, clearWhen, utm |
the variant |
DELETE /links/:id/variants/:name |
— | { "id", "variant", "deleted": true } (the last variant cannot be deleted) |
when keys (arrays of strings; every set key must match): countries (ISO-2), devices (mobile | tablet | desktop), os (windows | macos | android | ios | linux | other), browsers (edge | chrome | firefox | safari | other), languages (prefix), referrerHosts (host suffix).
Query parameter rules
when.params is an array of up to 10 rules on the visitor’s query string (lida.sh/promo?src=newsletter); every rule must hold. Each rule is { "key", "op", "value" } with op one of equals | notEquals | includes | startsWith | endsWith | in (value is a |- or comma-separated list) | exists | missing. value is required except for exists / missing. Keys are case-sensitive, values compare exactly.
{ "params": [{ "key": "src", "op": "equals", "value": "newsletter" }, { "key": "gclid", "op": "missing" }] }
Parameter values are matched at open time and never stored in analytics.
Forward query parameters
forwardParams (boolean on the link, default false; settable on POST /links and PATCH /links/:id, returned by GET /links/:id) appends the visitor’s whole query string to URL variants. Visitor values override stored ones, utm included. File and text variants ignore it. This is routing, not access control: anyone can add parameters. Parameter values are not stored in analytics.
Errors
| Status | When |
|---|---|
| 400 | Validation, missing body, mixed create kinds |
| 401 | Missing or invalid bearer key |
| 404 | Unknown slug or domain |
| 409 | Slug taken (code: "slug_taken") |
| 429 | Monthly event quota reached |
| 503 | API keys not configured on this deployment |
See webhooks and custom domains for those endpoints.

