---
title: "Create a link"
method: POST
path: "/api/links"
tags: ["Links"]
---

# Create a link

`POST /api/links`

## Request

- Base URL: `https://www.flyn.to`
- URL: `https://www.flyn.to/api/links`
- Auth: HTTP bearer

Creates one short link with the full option set and returns it. Sends a link.create webhook to the calling user's webhooks.

**Auth:** API key (`Bearer flyn_sk_live_...`) or an MCP OAuth access token (`Bearer flyn_mcp_at_...`). The dashboard session cookie is not accepted.

**Plan:** Free accounts can create 25 links per UTC calendar month (counted per workspace), may not use a custom domain, and may not set the fields marked Pro; each returns 403 `UPGRADE_REQUIRED`. Pro, Lifetime or Team (a member of a paying owner's workspace has the effective plan `team`) have no limits here. The viewer team role gets 403.

**Rate limit:** 10 requests per 60 s per client IP, fixed window, in memory per server instance, checked before authentication.

An invalid JSON body returns 500, not 400.

## Request body

- LinkCreateRequest: Fields marked **Pro** return 403 `UPGRADE_REQUIRED` (with `premiumFields`) on a Free plan when sent with a real value. An empty string, false, 0, [] or {} does not count as a value; targeting and rotation count only when they normalise to real rules.
  - `url` string, required: Destination. Trimmed; `https://` is prepended unless it starts with http:, https:, mailto:, tel:, sms: or ftp:. ftp: is always refused. For http(s): the host must contain a dot, must not be a Flyn host, and must resolve in DNS to a public IP (else 400 "Private/internal URLs are not allowed"). mailto:, tel: and sms: skip host checks. Then a safety scan (422 when blocked); a deeper scan runs after the response and can disable the link later.
  - `slug` string: Custom back-half. Not a reserved word (case-insensitive). Default: 6 random characters. Unique per domain (409 on clash).
  - `domain` string: Short domain. Default: the current Flyn short domain; the legacy value `www.flyn.to` is treated as a request for that default. Any other value must exactly match (case-sensitive) a custom domain owned by the workspace, else 403. **Pro** for any custom domain.
  - `title` string: Display title. Default: the part of the URL after `//` up to the next `/` (usually the hostname, but it keeps any port or userinfo, and a query or fragment when there is no path), else "Untitled".
  - `tags` string[]: **Pro.** Tags. Default [].
  - `password` string: **Pro.** Password gate (stored as a bcrypt hash). An empty string is ignored.
  - `expiresAt` string, date-time: Expiry (ISO 8601). Must parse as a date; past dates are accepted and stored as sent.
  - `folderId` string, uuid: Folder in the workspace, else 404.
  - `ogTitle` string: Social preview title.
  - `ogDescription` string: Social preview description.
  - `ogImage` string, uri: Social preview image. http or https; the host must resolve to a public IP.
  - `targeting` Targeting: Smart-routing rules as stored. `device` takes four keys, `ios`, `android`, `windows` and `macos`, each applying only to visitors on that operating system, and a device rule takes priority. There is no `desktop` key: Linux, ChromeOS and any other or unrecognised system match no device rule and fall through to the link's other rules. `windows` and `macos` must be http or https URLs, and the aliases `mac`, `osx`, `win`, `desktop` and `linux` are rejected with 400 `INVALID_TARGETING`. `geo` maps ISO 3166-1 alpha-2 country codes (upper case) to a destination and applies to every visitor whose country is known, desktop included. A visitor matching no rule falls through to the rotation, or the legacy A/B split, when one is set, else goes to the link url. A PATCH replaces the whole targeting object; nothing is merged into the stored rules. Safari on an iPad usually sends the same user agent as Safari on a Mac, so on a link with a `macos` destination, Safari on a Mac or an iPad (or any browser sending that same user agent) first loads a blank check page that tells a Mac from an iPad, then continues at once. A browser with JavaScript off continues as a Mac. The check page records no click; the click is recorded once, when the browser continues to the short link with the reserved query parameters `flyn_os` and `flyn_ref` (the original referrer), which are never passed to a destination. Chrome, Edge and Firefox on a Mac skip the check page, and a link without a `macos` rule never shows it. Device and country rules, including the Safari check page, do not apply to password-protected links, to a link that has reached its click limit, or to mailto, tel and sms links.
    - `device` object
      - `ios` string: Destination for iOS visitors, such as an iPhone. Safari on an iPad usually sends the same user agent as Safari on a Mac (see Targeting).
      - `android` string: Destination for Android visitors.
      - `windows` string: Destination for Windows visitors. Must be an http or https URL, for example a Microsoft Store or download page.
      - `macos` string: Destination for Mac visitors. Must be an http or https URL, for example a Mac App Store or download page. Setting it turns on the Safari check page described on Targeting.
    - `geo` object: Country code to destination, for example {"US": "https://example.com/us"}.
  - `cloaking` boolean: **Pro.** Cloaking.
  - `noIndex` boolean: **Pro** when true. Accepted and stored for compatibility. It has no effect on how the link is served: every short link is already served with a noindex robots header on every plan, whether or not this is set.
  - `notes` string: Private notes.
  - `rotation` RotationEntryInput[]: **Pro.** Weighted rotation across up to 12 destinations (more returns 400). Each destination must be http or https, must not be a Flyn host (400 "Cannot shorten Flyn URLs"), must not resolve to a private address (400), and is safety scanned (422).
    - `id` string: Keep an existing destination id. Missing ids are minted (8 characters).
    - `url` string, required: Destination. `https://` is prepended when there is no scheme; must be http or https and resolve to a public IP (else 400); safety scanned (422). An entry with an empty or missing url is dropped silently, not rejected.
    - `weight` number, required: Relative weight. Non-finite or <= 0 becomes 0 (never served). If every weight is 0, no rotation is stored.
    - `label` string: Optional display label.
  - `abUrlB` string: **Pro.** Legacy A/B variant URL. http or https, not a Flyn host (400 "Cannot shorten Flyn URLs"), public IP, safety scanned (422).
  - `abSplit` number: **Pro.** Legacy A/B split, percent kept on the primary url. Clamped to 0..100; a non-finite value becomes 50.
  - `deepLink` boolean: **Pro.** Serve the native-app handoff page on mobile. null returns 400.
  - `pixelIds` string[]: **Pro.** Saved pixel ids to fire. Ids outside the workspace are dropped silently. If any id is not a valid UUID, every id is dropped (no error).
  - `forwardParams` boolean: Pass the short link's query string through to the destination.
  - `clickLimit` integer: **Pro.** Maximum human clicks. null or "" is ignored.
  - `fallbackUrl` string: **Pro.** Destination once the click limit is reached. `https://` is prepended; http or https, not a Flyn host, public IP, safety scanned (422).
  - `conversionTracking` boolean: **Pro.** Only the literal `true` enables it.

## Response `201`

The created link.

- Link: A short link, as returned by every /api/links endpoint and sent in link.create, link.update and link.delete webhook deliveries. Every field is always present.
  - `id` string, uuid, required: Link id.
  - `slug` string, required: Back-half of the short URL.
  - `url` string, required: Destination URL.
  - `shortUrl` string, uri, required: Full short URL, `https://<domain>/<slug>`.
  - `domain` string, required: Short domain the link lives on. New links default to the current Flyn short domain.
  - `title` string, nullable, required: Display title.
  - `clicks` integer, required: Human clicks (bots and scanners excluded). This is the headline number.
  - `botClicks` integer, required: Clicks filtered out as bots or scanners.
  - `qrScans` integer, required: Clicks that arrived from a Flyn QR code; a subset of `clicks`.
  - `totalClicks` integer, required: `clicks` + `botClicks`.
  - `status` 'active' | 'expired' | 'archived', required: Stored status. A link past `expiresAt` keeps `active` here; the redirect checks the expiry itself.
  - `tags` string[], required
  - `createdAt` string, date-time, required
  - `expiresAt` string, nullable, required: Expiry as stored (ISO 8601), or null.
  - `folderId` string, uuid, nullable, required
  - `password` boolean, required: True when the link is password protected. The password itself is never returned.
  - `ogTitle` string, nullable, required
  - `ogDescription` string, nullable, required
  - `ogImage` string, nullable, required
  - `targeting` Targeting, required: Smart-routing rules as stored. `device` takes four keys, `ios`, `android`, `windows` and `macos`, each applying only to visitors on that operating system, and a device rule takes priority. There is no `desktop` key: Linux, ChromeOS and any other or unrecognised system match no device rule and fall through to the link's other rules. `windows` and `macos` must be http or https URLs, and the aliases `mac`, `osx`, `win`, `desktop` and `linux` are rejected with 400 `INVALID_TARGETING`. `geo` maps ISO 3166-1 alpha-2 country codes (upper case) to a destination and applies to every visitor whose country is known, desktop included. A visitor matching no rule falls through to the rotation, or the legacy A/B split, when one is set, else goes to the link url. A PATCH replaces the whole targeting object; nothing is merged into the stored rules. Safari on an iPad usually sends the same user agent as Safari on a Mac, so on a link with a `macos` destination, Safari on a Mac or an iPad (or any browser sending that same user agent) first loads a blank check page that tells a Mac from an iPad, then continues at once. A browser with JavaScript off continues as a Mac. The check page records no click; the click is recorded once, when the browser continues to the short link with the reserved query parameters `flyn_os` and `flyn_ref` (the original referrer), which are never passed to a destination. Chrome, Edge and Firefox on a Mac skip the check page, and a link without a `macos` rule never shows it. Device and country rules, including the Safari check page, do not apply to password-protected links, to a link that has reached its click limit, or to mailto, tel and sms links.
    - `device` object
      - `ios` string: Destination for iOS visitors, such as an iPhone. Safari on an iPad usually sends the same user agent as Safari on a Mac (see Targeting).
      - `android` string: Destination for Android visitors.
      - `windows` string: Destination for Windows visitors. Must be an http or https URL, for example a Microsoft Store or download page.
      - `macos` string: Destination for Mac visitors. Must be an http or https URL, for example a Mac App Store or download page. Setting it turns on the Safari check page described on Targeting.
    - `geo` object: Country code to destination, for example {"US": "https://example.com/us"}.
  - `cloaking` boolean, required
  - `noIndex` boolean, required: Stored value of the legacy per-link no-index flag. It has no effect on how the link is served: every short link is already served with a noindex robots header on every plan, whether or not this is set.
  - `notes` string, nullable, required: Private notes.
  - `abUrlB` string, nullable, required: Legacy two-way A/B view of `rotation`, kept for older integrations.
  - `abSplit` number, nullable, required: Legacy A/B split: percent kept on the primary url.
  - `rotation` RotationEntry[], nullable, required: Weighted destinations, or null when the link does not rotate.
    - `id` string, required: Stable destination id (8 characters when minted by Flyn). Reported as `variant` on link.click deliveries.
    - `url` string, uri, required: Destination URL.
    - `weight` number, required: Relative weight, not a percentage. Weights are normalised when a visitor is routed.
    - `label` string, nullable, required: Optional display label.
  - `deepLink` boolean, required: Native-app handoff mode on mobile.
  - `pixelIds` string[], required: Retargeting pixels fired on click.
  - `forwardParams` boolean, required: Query-string passthrough to the destination.
  - `clickLimit` integer, nullable, required: Maximum human clicks. Once reached, visitors go to `fallbackUrl`, or to the expired-link page when no http(s) `fallbackUrl` is set.
  - `fallbackUrl` string, nullable, required: Destination after the click limit is reached.
  - `conversionTracking` boolean, required: When true, each human click gets a `flyn_id` click id for conversion pings.
  - `conversions` integer, required: Recorded conversions, net of refunds (never below 0).
  - `conversionValueCents` integer, required: Summed conversion value in minor units.
  - `conversionCurrency` string, nullable, required: Currency of the summed value.

## Other responses

- `400`: Validation failed. Messages include "Request body must be valid JSON", "Request body must be a JSON object", "deepLink must be true or false", "url is required", "Unsupported URL protocol", "Invalid URL", "Cannot shorten Flyn URLs", "Private/internal URLs are not allowed", "Slug can only contain letters, numbers, hyphens, and underscores", "Slug must be 100 characters or fewer", "This slug is reserved. Please choose a different one.", "A link can rotate between at most 12 destinations. You sent 15.", "Invalid expiration date", "Click limit must be a whole number between 1 and 2147483647", and targeting errors with code `INVALID_TARGETING`.
- `401`: Missing header, wrong prefix, unknown or revoked key, expired key, or an invalid MCP OAuth token. Messages: "Missing or invalid API key. Use: Authorization: Bearer flyn_sk_live_...", "Invalid or revoked API key", "API key has expired", "Invalid or expired OAuth token".
- `403`: Viewer role ("You do not have permission to create links in this workspace. Contact your team admin."), Free monthly cap (code `UPGRADE_REQUIRED` with `limit` and `used`), Free custom domain or Pro field (code `UPGRADE_REQUIRED` with `premiumFields`), custom domain not owned ("Domain not found or not owned by you"), or a suspended account.
- `404`: folderId is not in the workspace.
- `409`: The slug is already taken on that domain.
- `422`: The destination, a rotation URL, abUrlB, fallbackUrl or a targeting URL failed the safety scan.
- `429`: More than 10 requests in the window. Counted per client IP. Retry-After gives the seconds until the window resets.
- `500`: A non-string password or a database error.

---

[API](https://skmtc.dev/flyn/apis/flyn-api.md) · [All operations](https://skmtc.dev/flyn/apis/flyn-api/llms.txt) · [OpenAPI document](https://skmtc.dev/flyn/apis/flyn-api/revisions/977962a98ccb?raw)
