--- 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. --- --- url: /cli.md --- # Command-line interface (CLI) `dynm.link` manages your links from the terminal and runs an [MCP server](/agents) for agents. It talks to the `/v1` [REST API](/quickstart) with your API key. ## Installation ```bash npm install -g @dynm.link/cli ``` Or run it without installing: `npx @dynm.link/cli `. [`@dynm.link/cli` on npm](https://www.npmjs.com/package/@dynm.link/cli) ## Authentication ```bash dynm.link login # opens your browser to authorize, then stores the key dynm.link login --key dynm_... # headless: pass a key (or pipe it: echo dynm_... | dynm.link login) dynm.link whoami # user, plan, folders, API URL dynm.link logout # forget the stored key ``` `dynm.link login` opens the dashboard with a one-time localhost callback; approve while signed in and the new key comes straight back. On a remote or headless machine, create a key on the dashboard's [API keys page](https://dash.dynm.link/account/api-keys) and pass it with `--key`. Either way the key is checked against the API before it is stored. The key lives in `~/.config/dynm.link/config.json` (`$XDG_CONFIG_HOME/dynm.link/config.json` when set), readable only by you. A config file that isn't valid fails every command, naming the file, and is never overwritten: fix it, or delete it and log in again. ### Environment variables These override the stored config: * `DYNM_API_KEY` — the API key. * `DYNM_API_URL` — the API's origin (default `https://api.dynm.link`). * `DYNM_SPA_URL` — the dashboard `login` opens (default: `dash.` beside the API, so `https://dash.dynm.link`). ## Links ```bash dynm.link link list [--type redirect|text|bio|file] [--domain ] [--limit ] [--json] # alias: ls dynm.link link get [--domain ] [--json] dynm.link link edit [--title …] [--description …] [--expires …] [--web ] [--preview follow|custom] [--content ''] [--domain ] [--json] dynm.link link remove [--domain ] [--yes] # alias: rm ``` `list` shows every folder, newest first; `--type` and `--domain` filter it. `edit --content` replaces the content with the link's own type's, whole (the shapes are in [Link types](/quickstart#link-types)); `--web` and `--preview` change one field of it. ### Create One subcommand per type: ```bash dynm.link link create redirect [slug] --web [--ios ] [--android ] [--rule '']… [--preview follow|custom] dynm.link link create text [slug] --body "Some text" [--markdown] # --body - reads stdin dynm.link link create bio [slug] --name "Jason" [--headline …] [--avatar ] --link "Site=https://…" --link "GitHub=https://…" dynm.link link create file [slug] --file ./brand-kit.zip ``` Every `create` also takes `--title`, `--description`, `--expires`, `--domain` and `--json`, and generates the slug when it's omitted. `--expires` is `30d`, `12h`, `45m`, a date (`YYYY-MM-DD`) or `never`; without it a link expires in 30 days. `create file` uploads the file, then creates the link. `--rule` takes a full [routing rule](/quickstart#routing-rules), e.g. `--rule '{"when":[{"op":"in","field":"country","values":["US","CA"]}],"to":"https://…/na"}'`; `--ios` / `--android` add platform rules ahead of them. A redirect's share preview follows its destination by default (a share unfurls the destination's own card); `--title` / `--description` on a redirect need `--preview custom`. `dynm.link link edit --preview follow` switches back and clears the title, description and card image. ### Folders: `--domain` `--domain` picks the folder. It's optional while your account has only its `username.dynm.link` subdomain, and **required** once you have an active custom domain, so create, get, edit and remove are never ambiguous. On `list` it's only a filter. ## Errors A failed command prints one line to stderr and exits 1. An API error prints as its code and message, e.g. `SlugTakenError: alice.dynm.link/launch is taken.` --- --- url: /agents.md --- # MCP & agents `dynm.link mcp` runs a Model Context Protocol server on stdio, so an agent (Claude Desktop, Cursor, Antigravity, …) can manage your links. ## Configuration Install the [CLI](/cli), then add the server to your agent's MCP config (e.g. `claude_desktop_config.json`): ```json { "mcpServers": { "dynm-link": { "command": "dynm.link", "args": ["mcp"], "env": { "DYNM_API_KEY": "dynm_..." } } } } ``` Without `DYNM_API_KEY` the server uses the key `dynm.link login` stored. ## Tools | Tool | What it does | | --- | --- | | `list_links` | Your links across all folders, newest first (`limit`, default 1000). | | `get_link` | One link by folder `host` + `slug`. | | `check_slug` | Whether a slug is free in a folder (`host` + `slug`), and why not. | | `create_link` | A link of any `type`, with that type's `content`; `host` picks the folder, as the CLI's `--domain` does. | | `update_link` | Change a link by `id`: only the fields given, `null` clears one. New `content` is the link's own type's, whole. | | `delete_link` | Delete a link by `id`, with its file and card image. | `update_link` and `delete_link` take the `id` from `list_links` or `get_link`. Parameters are the API's own schemas (the shapes are in [Link types](/quickstart#link-types)), so a wrong shape is refused naming the field. A failed call returns the same `code: message` line the CLI prints, e.g. `SlugTakenError: alice.dynm.link/launch is taken.` ## Files The MCP server doesn't upload. A `file` link's `content` takes the `r2Key` of a file already uploaded with [`POST /v1/uploads`](/quickstart#upload-a-file); to upload a local file and link it in one step, use `dynm.link link create file`.