---
url: /quickstart.md
---
# REST API quickstart

The API is at `https://api.dynm.link`, every path under `/v1`, JSON in and out. Every field of
every schema is in the [REST API reference](https://api.dynm.link/reference) and the raw
[OpenAPI document](https://api.dynm.link/openapi.json).

## Authentication

Send an API key as a Bearer token:

```http
Authorization: Bearer $DYNM_API_KEY
```

An `x-api-key: $DYNM_API_KEY` header works too. Create a key on the dashboard's
[API keys page](https://dash.dynm.link/account/api-keys), or with `dynm.link login` (see the
[CLI](/cli)). A key that is invalid, expired or revoked gets `401 UnauthorizedError`.

## Create a link

```bash
curl https://api.dynm.link/v1/microlinks \
  -H "Authorization: Bearer $DYNM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"redirect","content":{"web":"https://example.com"}}'
```

`201` returns the link. Its `id` is what every other link endpoint takes:

```json
{
  "id": "01K6H2M9V7Q4XJ8C3N5R2T6W1Y",
  "host": "alice.dynm.link",
  "slug": "k7m2qp",
  "url": "https://alice.dynm.link/k7m2qp",
  "type": "redirect",
  "content": { "web": "https://example.com", "rules": [], "preview": "follow" },
  "title": null,
  "description": null,
  "thumbnail": null,
  "expiresAt": 1762041600000,
  "createdAt": 1759449600000,
  "updatedAt": 1759449600000
}
```

The other create fields are optional:

* `host` — the folder: your `{username}.dynm.link` subdomain when omitted, or one of your active
  [custom domains](#custom-domains).
* `slug` — the path in the folder; generated when omitted. Lowercase letters, digits and single
  hyphens; `/` separates segments, a trailing `*` matches the rest of the path, and an empty slug
  publishes at the folder's root.
* `expiresAt` — when the link stops resolving, in epoch milliseconds; `null` never. Omitted, 30
  days from now.
* `title`, `description`, `thumbnail` — the link's social card (see
  [below](#social-preview-on-a-redirect)).

## Link types

`type` decides the shape of `content`. Every URL must be `http` or `https`, except a bio link's,
which may also be a `mailto:` or `tel:`.

| `type` | `content` |
| --- | --- |
| `redirect` | `{ web, rules?, preview? }` — `web` is where visitors go when no rule matches |
| `text` | `{ body, markdown? }` — `markdown: true` renders the body as a page |
| `bio` | `{ name, headline?, avatar?, links: [{ label, url }] }` |
| `file` | `{ r2Key, filename }` — an [upload](#upload-a-file); the response adds `size` and `mime` |

### Routing rules

A redirect sends each visitor to the first rule whose `when` clauses all match, else to `web`. A
clause is `{ op, field, value, negate? }`: `op` is `eq`, `lt`, `lte`, `gt` or `gte` (or `in`, with
`values` instead of `value`); `field` is `platform` (`ios`, `android` or `other`), `country`
(ISO 3166 alpha-2) or `now` (epoch milliseconds).

```bash
curl https://api.dynm.link/v1/microlinks \
  -H "Authorization: Bearer $DYNM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"redirect","slug":"app","content":{
        "web":"https://example.com",
        "rules":[
          {"when":[{"op":"eq","field":"platform","value":"ios"}],"to":"https://apps.apple.com/app/id000000000"},
          {"when":[{"op":"in","field":"country","values":["US","CA"]}],"to":"https://example.com/na"}
        ]}}'
```

### Social preview on a redirect

By default a redirect passes crawlers straight through to its destination, so pasting the short
link in Slack, iMessage or X shows the **destination page's own** card. Set `content.preview` to
`custom` to serve your own card instead:

```bash
curl https://api.dynm.link/v1/microlinks \
  -H "Authorization: Bearer $DYNM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"redirect","content":{"web":"https://example.com","preview":"custom"},
       "title":"Launch week","description":"Everything shipping this week"}'
```

The card image (`thumbnail`) is the `r2Key` of an [upload](#upload-a-file). The two are exclusive:
`title`, `description` or `thumbnail` on a redirect whose `preview` is `follow` returns
`400 PreviewConflictError`. To drop a custom card, set `preview` back to `follow` and those fields
to `null` in the same request.

## Upload a file

A file goes straight to storage; the API says where. With `jq` to build and read the JSON:

```bash
FILE=brand-kit.zip MIME=application/zip

# 1. Start the upload: the file's name, size in bytes and type.
UPLOAD=$(curl -s https://api.dynm.link/v1/uploads \
  -H "Authorization: Bearer $DYNM_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg filename "$FILE" --arg mime "$MIME" --argjson size "$(wc -c < "$FILE")" \
        '{$filename, $size, $mime}')")
# → { "r2Key": "uploads/…/brand-kit.zip", "uploadUrl": "https://…", "maxBytes": 52428800 }

# 2. PUT the bytes to uploadUrl, with the file's Content-Type and no API key.
curl -X PUT "$(jq -r .uploadUrl <<<"$UPLOAD")" \
  -H "Content-Type: $MIME" \
  --data-binary @"$FILE"

# 3. Create a file link that references the r2Key.
curl https://api.dynm.link/v1/microlinks \
  -H "Authorization: Bearer $DYNM_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg r2Key "$(jq -r .r2Key <<<"$UPLOAD")" --arg filename "$FILE" \
        '{type: "file", content: {$r2Key, $filename}}')"
```

* `uploadUrl` carries its own authorization and is good for an hour. The `Content-Type` you PUT
  is what the file is served as.
* There is no confirm step: creating the link claims the upload and checks the stored file's real
  size against your plan (`413 FileTooLargeError`). An upload no link references is deleted after
  a day.
* A file link's slug is a plain one: no `/`, no `*`, not the root.

## Manage links

| Request | Does |
| --- | --- |
| `GET /v1/microlinks` | Your links, newest first, 25 a page (`limit` up to 100). Pass `nextCursor` back as `cursor` for the next page; it is `null` on the last. |
| `GET /v1/microlinks/find?host=…&slug=…` | Your link at a folder and slug. |
| `GET /v1/microlinks/slug-availability?host=…&slug=…` | `{ "available": true }`, or `{ "available": false, "reason", "message" }` with `reason` `invalid`, `reserved` or `taken`. |
| `GET /v1/microlinks/{id}` | One link. |
| `PATCH /v1/microlinks/{id}` | Sets only the fields given; `null` clears an optional one. New `content` replaces the old whole and must be the link's own type's. |
| `DELETE /v1/microlinks/{id}` | Deletes the link, with its file and card image. |
| `GET /v1/microlinks/{id}/qr` | The short URL as an SVG QR code. |

```bash
curl -X PATCH https://api.dynm.link/v1/microlinks/$LINK_ID \
  -H "Authorization: Bearer $DYNM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Launch week","expiresAt":null}'
```

## Analytics

* `GET /v1/microlinks/{id}/stats?range=7d` — views, previews, downloads and bio clicks per UTC day,
  with breakdowns by referrer, country, device, routing rule, bio link and query parameter. `range`
  is `24h`, `7d` (the default), `30d` or `12mo`. Free accounts get the 7-day range only, without
  breakdowns (`depthLocked: true`).
* `POST /v1/microlinks/stats/views` with `{ "ids": [...] }` (1 to 100) — the last 7 days of views
  for each of those links you own.

### Query parameters

* **Passthrough:** a redirect forwards the query parameters it is visited with to its destination.
  A parameter on both replaces the destination's value.
* **Segments:** every query parameter is recorded as an analytics dimension, so `?src=yt` or
  `?campaign=launch` splits your visits.
* **Reserved:** parameters whose decoded key starts with `_` are never forwarded or recorded.
* **QR codes:** the QR encodes the bare short URL. To count QR scans separately, print a QR of the
  link with your own tag, e.g. `?src=qr`.

## Custom domains

A domain you own serves links as a folder of its own (Pro: on Free, adding one returns
`403 ProRequiredError` with `wall: "custom-domains"`).

```bash
curl https://api.dynm.link/v1/domains \
  -H "Authorization: Bearer $DYNM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"hostname":"links.example.com"}'
```

The domain starts `pending`. Publish a TXT record named `verifyName` with the value `verifyValue`,
and a CNAME from the hostname to `cnameTarget`. It turns `active` once both are in place and its
certificate is issued; pending domains are checked in the background, and
`POST /v1/domains/{id}/check` checks now and reports what DNS showed (`verification`). Then create
links in it with `"host": "links.example.com"`.

`GET /v1/domains` lists yours, `GET /v1/domains/{id}` reads one, and `DELETE /v1/domains/{id}`
removes it along with its links.

## Account

`GET /v1/me` returns your account: `username`, plan (`tier`), its `limits` (`null` is unlimited)
and your `usage` against them.

## Without an API key

`POST /v1/microlinks` and `POST /v1/uploads` also work with no credential. The link lands on the
shared `dynm.link` apex with a generated slug and expires after 30 days; bio pages and chosen slugs
need an account (`403 SignInRequiredError`). Anonymous uploads are capped at 50 MB, and links and
uploads share a per-IP hourly cap (`429 RateLimitedError`).

## Errors

Every error body carries a `code` to match on, a `message` for a person, and the error's own
fields:

```json
{ "code": "SlugTakenError", "message": "alice.dynm.link/launch is taken.", "slug": "launch" }
```

| Status | `code` | Fields | When |
| --- | --- | --- | --- |
| 400 | `InvalidRequestError` | | The body, query or path doesn't match the schema; `message` names the field. |
| 400 | `InvalidSlugError` | `slug` | A reserved slug, or a shape the folder doesn't take. |
| 400 | `SelfRedirectError` | | A redirect that would point back at itself. |
| 400 | `PreviewConflictError` | | A custom card on a redirect whose `preview` is `follow`. |
| 400 | `InvalidContentError` | | New `content` that isn't the link's own type's. |
| 400 | `InvalidDomainError` | | A hostname that can't be served as a custom domain. |
| 401 | `UnauthorizedError` | | No API key, or one that is invalid, expired or revoked. |
| 403 | `ProRequiredError` | `wall` | A plan limit: `links`, `storage` or `custom-domains`. |
| 403 | `SignInRequiredError` | | An anonymous caller asked for a bio page or a chosen slug. |
| 404 | `MicrolinkNotFoundError` | | No link of yours with that id, or at that host and slug. |
| 404 | `FolderNotFoundError` | `host` | `host` isn't your subdomain or one of your domains. |
| 404 | `UploadNotFoundError` | `r2Key` | No upload of yours with that key. |
| 404 | `DomainNotFoundError` | | No domain of yours with that id. |
| 409 | `SlugTakenError` | `slug` | The slug is taken in that folder. |
| 409 | `UploadInUseError` | `r2Key` | Another link already uses that upload, or it's this link's file and card image at once. Upload the file again. |
| 409 | `DomainAlreadyAddedError` | `id` | You've already added this hostname; `id` is that domain's. |
| 409 | `AccountSetupRequiredError` | | The account hasn't claimed its username on the dashboard yet. |
| 413 | `FileTooLargeError` | `maxBytes` | The file is over your per-file limit. |
| 429 | `RateLimitedError` | | Anonymous use from this IP hit its hourly cap. |

The reference lists the errors each endpoint can return.
