# Linkdash HTTP API

Base URL: `https://api.linkdash.dev/v1`

Auth: `Authorization: Bearer ldash_...` (create keys at https://linkdash.dev/dashboard/keys). Do not invent keys.

Same operations as the CLI and MCP. CLI: [cli.md](https://linkdash.dev/cli.md). MCP: [mcp.md](https://linkdash.dev/mcp.md). Pricing / quotas: [pricing.md](https://linkdash.dev/pricing.md). Discovery (no auth): `GET https://api.linkdash.dev/v1`.

Public ids are slugs (`deck`, `k3m9xq`), not Convex `_id`. JSON field names match CLI stdout (`id`, `url`, `kind`, …).

## Conventions

- JSON `Content-Type: application/json` unless noted.
- Create takes exactly one of `url`, `text`, or `storageId` (after `POST /uploads`). Small files may still use multipart `file` on `POST /links`. The mint response is `{ "uploadUrl", "storageId" }`. The host PUTs bytes to `uploadUrl`, then creates with that `storageId`.
- 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. `openCount` is openings so far. Raise `maxOpenings`, or `clearMaxOpenings`, to serve it again. Omit `maxOpenings` for unlimited.
- If a slug exists on two of the owner’s domains, pass `?domain=` / JSON `domain`.
- Errors: `{ "error": "..." }` plus optional `code`. Taken `lida.sh` slugs return `409` with `code: "slug_taken"`.
- Writes count toward the monthly event quota. List / get / stats / export / webhook list/get / mint upload URL / domain connect 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` |
| (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.

## Links

### `GET /links`

Query: `q` (name, slug, target, tags), `tag`, `kind` (`url` \| `file` \| `text`), `status` (`active` \| `paused` \| `expired` \| `scheduled`).

Each item:

```json
{
  "id": "deck",
  "kind": "url",
  "status": "active",
  "name": "Q4 deck",
  "tags": ["launch"],
  "url": "https://lida.sh/deck",
  "target": "https://example.com/q4/deck.pdf",
  "_id": "…"
}
```

### `GET /links/:id`

Optional `?domain=go.example.com`. Full link plus `url`. Includes `slug`, `kind`, `status`, `name`, `tags`, `destinationUrl` / `textContent` / `fileName`, `startsAt`, `expiresAt`, `maxOpenings`, `openCount`, `previewName`, `previewDescription`, `previewImageUrl`, `domainHostname`, `hasPassword`. No password plaintext. `maxOpenings` is omitted when unlimited. `openCount` is openings so far.

### `POST /uploads`

Quota check, then a Tigris upload URL. Response: `{ "uploadUrl", "storageId" }`. The **host** PUTs the file bytes to `uploadUrl`, then `POST /links` with that `storageId` in the same flow:

```bash
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: video/mp4" --data-binary @clip.mp4 "$uploadUrl"

curl https://api.linkdash.dev/v1/links \
  -H "Authorization: Bearer ldash_..." \
  -H "Content-Type: application/json" \
  -d '{"storageId":"…","fileName":"clip.mp4","fileType":"video/mp4","fileSize":12345,"previewName":"Clip"}'
```

Response of mint: `{ "uploadUrl": "https://…", "storageId": "pending/…" }`. Tigris PUT does not return JSON — use the minted `storageId`. Do not treat `uploadUrl` as a shareable API. `DELETE /uploads/:storageId` (or JSON `{ "storageId" }`) removes an unused blob.

A preview image uses the same mint + host PUT, then `previewImageStorageId` on create/update.

### `POST /links`

JSON (exactly one of `url`, `text`, or `storageId`):

```json
{
  "url": "https://example.com",
  "slug": "welcome",
  "name": "Homepage",
  "tags": ["launch"],
  "starts": "7d",
  "expires": "30d",
  "maxOpenings": 1,
  "password": "…",
  "domain": "go.example.com",
  "previewName": "Welcome",
  "previewDescription": "…",
  "utm": { "source": "twitter", "medium": "social", "campaign": "q4" }
}
```

Text: `{ "text": "release checklist" }` plus the same optional fields. UTM only on URL links. File: `{ "storageId", "fileName", "fileType", "fileSize" }` plus optional preview fields.

Multipart still creates immediately for small files:

```bash
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`.

Response: `{ "id", "url", "kind", … }`.

### `PATCH /links/:id`

JSON or multipart. Optional `?domain=`.

Settable: `url` / `destinationUrl`, `text` / `textContent`, `status` (`active` \| `paused`), `slug`, `name`, `tags`, `starts` / `startsAt`, `expires` / `expiresAt`, `maxOpenings`, `password`, `domain`, `previewName`, `previewDescription`, `previewImageStorageId`, `utm`, plus `clearPassword`, `clearExpires`, `clearStarts`, `clearTags`, `clearPreview`, `clearName`, `clearDomain`, `clearMaxOpenings`.

Replace file bytes: JSON `storageId` after `POST /uploads` + host PUT, or multipart field `file` (small files). Preview image: `previewImageStorageId` or multipart `previewImage`.

`--status` / `{ "status": "expired" }` is rejected. Use `expires`. `{ "status": "scheduled" }` is rejected. Use `starts`.

### `DELETE /links/:id`

`{ "id": "deck", "deleted": true }`. Optional `?domain=`.

### `GET /links/:id/stats`

Query `last` (default `24h`). JSON: `id`, `available`, `visits`, `downloads`, `plays`, `last`, `visitsInLast`, `countries`, `referrers`, `visitors`.

### `GET /links/export`

CSV (`text/csv`). Columns: `slug,kind,name,url,text,fileName,status,domain,tags,starts,expires,maxOpenings,created`. No passwords.

### `POST /links/import`

Raw CSV body or multipart `file`. Creates/updates URL and text rows by slug. File rows without bytes are skipped (`error` per row). Response: `{ "imported": 3, "rows": [ { "slug", "ok", "error", "id" } ] }`.

## Webhooks

HTTPS endpoints. HTTPS only. No localhost or private IPs. Up to 10 per account.

Payload:

```json
{
  "type": "link.visit",
  "timestamp": "2026-08-17T16:00:00.000Z",
  "data": { "id": "deck", "kind": "file", "country": "DE", "referrer": "…" }
}
```

`type` is `link.visit` \| `link.download` \| `link.play`.

Headers on every delivery (Svix retries reuse the same id):

- `svix-id`
- `svix-timestamp` (unix seconds)
- `svix-signature` (`v1,<base64 hmac>`)

Signed content: `{id}.{timestamp}.{raw body}` HMAC-SHA256. Secret format `whsec_` + base64. Shown **once** on `POST /webhooks` / `POST /webhooks/:id/rotate`. `GET` returns `secretHint` only (`whsec_…xxxx`).

```bash
curl https://api.linkdash.dev/v1/webhooks \
  -X POST \
  -H "Authorization: Bearer ldash_..." \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks"}'
```

Verify with [`svix`](https://www.npmjs.com/package/svix) (Standard Webhooks). Pass the **raw** body, not parsed JSON.

```bash
npm i svix
```

```ts
import { Webhook, WebhookVerificationError } from "svix";

const wh = new Webhook(process.env.LINKDASH_WEBHOOK_SECRET); // whsec_… shown once

try {
  const event = wh.verify(rawBody, {
    "svix-id": headers["svix-id"],
    "svix-timestamp": headers["svix-timestamp"],
    "svix-signature": headers["svix-signature"],
  });
  // event.type is "link.visit" | "link.download" | "link.play"
} catch (err) {
  if (err instanceof WebhookVerificationError) {
    // 400 — do not process
  }
  throw err;
}
```

## Domains

```bash
POST /domains          { "hostname": "docs.example.com" }
GET /domains/:host/connect
POST /domains/:host/verify
DELETE /domains/:host
GET /domains
```

Free includes 1 custom domain. Professional 5. Growth unlimited. After add, create every DNS record in the response (CNAME or ALIAS plus TXT), then verify. `GET /domains/:host/connect` returns `{ applyUrl, providerName, … }` when Unkey can one-click the records (`applyUrl` is `null` otherwise). Assign with `domain` on create/update.

## 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"` on the shared `lida.sh` namespace includes a custom-domain hint) |
| 429 | Monthly event quota reached |
| 503 | API keys not configured on this deployment |

## Agent rules

1. Use only the endpoints in this file. Files: `POST /uploads` returns `{uploadUrl,storageId}`, host PUTs to `uploadUrl`, then `POST /links` with that `storageId`.
2. Never put secrets in examples you echo back.
3. After adding or renaming a CLI command or HTTP path, update this file, [cli.md](https://linkdash.dev/cli.md), [mcp.md](https://linkdash.dev/mcp.md), `packages/skill/SKILL.md`, and the keys page in the same change.
