Appearance
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 and the raw OpenAPI document.
Authentication
Send an API key as a Bearer token:
http
Authorization: Bearer $DYNM_API_KEYAn x-api-key: $DYNM_API_KEY header works too. Create a key on the dashboard's API keys page, or with dynm.link login (see the 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.linksubdomain when omitted, or one of your active 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;nullnever. Omitted, 30 days from now.title,description,thumbnail— the link's social card (see below).
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; 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. 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}}')"uploadUrlcarries its own authorization and is good for an hour. TheContent-Typeyou 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.rangeis24h,7d(the default),30dor12mo. Free accounts get the 7-day range only, without breakdowns (depthLocked: true).POST /v1/microlinks/stats/viewswith{ "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=ytor?campaign=launchsplits 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.