Links
Create, update, delete, and list short links. Supports custom slugs, domains, tags, passwords, expiration, smart routing, weighted rotation, and custom OG metadata.
The Link Object
Every link endpoint returns the same standardized object shape:
| Field | Type | Description |
|---|---|---|
| id | string | Unique link ID (UUID) |
| slug | string | The short slug (e.g. "my-campaign") |
| url | string | The destination URL |
| shortUrl | string | Full short URL (e.g. "https://flyn.to/my-campaign") |
| domain | string | Domain used for this link |
| title | string | null | Display title for the link |
| clicks | number | Human clicks. Hits that Flyn identifies as bots or crawlers are not counted here (see botClicks) |
| botClicks | number | Hits filtered out of clicks as bots or crawlers |
| qrScans | number | Human clicks that arrived with Flyn's QR marker, flyn_qr=1, and no outside referrer. Codes from GET /api/links/:id/qr carry the marker. Already included in clicks. Unlocking a password-protected link never counts here, even after a scan |
| totalClicks | number | clicks plus botClicks |
| status | string | "active", "expired", or "archived" |
| tags | string[] | Array of tags for organizing links |
| createdAt | string | ISO 8601 creation timestamp |
| expiresAt | string | null | ISO 8601 expiration timestamp, or null |
| folderId | string | null | Folder ID if link is in a folder |
| password | boolean | Whether the link is password-protected (actual value is never exposed) |
| ogTitle | string | null | Custom Open Graph title |
| ogDescription | string | null | Custom Open Graph description |
| ogImage | string | null | Custom Open Graph image URL |
| targeting | object | null | Device rules (ios, android, windows, macos) and country rules (see Smart Routing below) |
| cloaking | boolean | Whether link cloaking is enabled |
| noIndex | boolean | Stored legacy 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 |
| notes | string | null | Private notes about the link |
| rotation | object[] | null | Weighted rotation destinations, or null when the link serves url only (see Link Rotation below) |
| abUrlB | string | null | Legacy A/B variant URL. Still supported as a 2-destination compatibility view of rotation |
| abSplit | number | null | Legacy A/B split: percent of traffic served the primary url, the rest goes to abUrlB |
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"slug": "my-campaign",
"url": "https://example.com/landing-page?utm_source=twitter",
"shortUrl": "https://flyn.to/my-campaign",
"domain": "www.flyn.to",
"title": "example.com",
"clicks": 1423,
"botClicks": 187,
"qrScans": 64,
"totalClicks": 1610,
"status": "active",
"tags": ["marketing", "q1-2026"],
"createdAt": "2026-03-15T10:30:00.000Z",
"expiresAt": null,
"folderId": null,
"password": false,
"ogTitle": "Check out our new product",
"ogDescription": "The fastest way to manage links",
"ogImage": "https://example.com/og-image.png",
"targeting": null,
"cloaking": false,
"noIndex": false,
"notes": null,
"rotation": null,
"abUrlB": null,
"abSplit": null
}Create a Link
Creates a new short link. If no slug is provided, a random 6-character slug is generated.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | Yes | The destination URL to shorten. Auto-prepends https:// if no protocol is provided. |
| slug | string | No | Custom slug (letters, numbers, hyphens, underscores). Max 100 chars. Auto-generated if omitted. |
| domain | string | No | Domain to use (must be a verified custom domain). Defaults to "www.flyn.to". |
| title | string | No | Display title. Defaults to the destination hostname. |
| tags | string[] | No | Array of tags for organizing links. |
| password | string | No | Password-protect the link. Users must enter the password before being redirected. |
| expiresAt | string | No | ISO 8601 expiration date. Link returns 410 Gone after this date. |
| folderId | string | No | UUID of a folder to organize the link into. |
| ogTitle | string | No | Custom Open Graph title for link previews. |
| ogDescription | string | No | Custom Open Graph description for link previews. |
| ogImage | string | No | Custom Open Graph image URL. Must be public HTTPS URL. |
| targeting | object | No | Smart routing rules: device rules for ios, android, windows and macos, and country rules (see Smart Routing section below). |
| cloaking | boolean | No | Enable link cloaking, shows your short URL in the browser address bar. |
| noIndex | boolean | No | Accepted for compatibility, with no effect on how the link is served: every short link is already served with a noindex robots header on every plan. Setting it to true is limited to Pro, Lifetime and Team. |
| notes | string | No | Private notes about the link (not visible to clickers). |
| rotation | object[] | No | Up to 12 weighted destinations to split traffic between (see Link Rotation below). Pro, Lifetime and Team only. |
curl -X POST https://www.flyn.to/api/links \
-H "Authorization: Bearer flyn_sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/landing-page",
"slug": "my-campaign",
"tags": ["marketing", "q1-2026"],
"ogTitle": "Check out our new product"
}'Response (201)
Returns the full Link object. Triggers link.create webhook event.
Error Codes
| Parameter | Type | Required | Description |
|---|---|---|---|
| 400 | error | No | Invalid URL, slug format, or missing required fields. A targeting object whose shape the redirect cannot honour is refused here too, with code "INVALID_TARGETING" and a message naming the offending keys. That includes a mac, osx, win, desktop or linux device key, and a windows or macos value that is not an http or https URL |
| 403 | error | No | A field limited to Pro, Lifetime and Team was set on a free account. Body carries code "UPGRADE_REQUIRED" and premiumFields, e.g. ["rotation"] |
| 409 | error | No | Slug is already taken on this domain |
| 422 | error | No | A destination failed the safety scan. Body carries code "DESTINATION_BLOCKED" |
| 429 | error | No | Rate limit exceeded (10 req/min) |
List Links
Returns a paginated list of your links with optional filtering and sorting.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| page | number | No | Page number (default: 1) |
| limit | number | No | Items per page (1-100, default: 50) |
| status | string | No | Filter by status: "active", "expired", or "archived" |
| search | string | No | Search in slug, destination URL, and title |
| tag | string | No | Filter by tag name |
| folder_id | string | No | Filter by folder UUID |
| sort | string | No | Sort by: "created_at" (default), "clicks", "slug", or "title" |
| order | string | No | "asc" or "desc" (default: desc) |
curl "https://www.flyn.to/api/links?page=1&limit=20&status=active&tag=marketing&sort=clicks&order=desc" \ -H "Authorization: Bearer flyn_sk_live_..."
Response (200)
{
"links": [ /* array of Link objects */ ],
"pagination": {
"page": 1,
"limit": 20,
"total": 142,
"totalPages": 8,
"hasMore": true
}
}Get a Link
Retrieves a single link by ID.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The UUID of the link |
Returns the full Link object, or 404 if not found.
Update a Link
Updates one or more fields on an existing link. Only include the fields you want to change.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | No | New destination URL |
| slug | string | No | New slug (must be unique on the domain) |
| domain | string | No | New domain: a custom domain your workspace owns (403 otherwise), or the default short domain to move the link back. A pending domain is accepted, but the link only serves once the domain is verified. null or an empty string returns 400. |
| title | string | No | New display title |
| status | string | No | New status: "active", "expired", or "archived" |
| tags | string[] | No | Replace all tags |
| password | string | No | Set or update password |
| expiresAt | string | No | New expiration date (ISO 8601) |
| folderId | string | No | Move to a different folder (Pro, Lifetime and Team; on Free, set folderId when you create the link) |
| ogTitle | string | No | Update custom OG title |
| ogDescription | string | No | Update custom OG description |
| ogImage | string | No | Update custom OG image URL |
| targeting | object | No | Replace the smart routing rules. The whole object is replaced, not merged, so send every rule you are keeping; null or {} removes them all (see Smart Routing below) |
| cloaking | boolean | No | Enable/disable link cloaking |
| noIndex | boolean | No | Accepted for compatibility, with no effect on how the link is served: every short link is already served with a noindex robots header on every plan. Setting it to true is limited to Pro, Lifetime and Team |
| notes | string | No | Update private notes |
| rotation | object[] | No | Replace the whole rotation list. Send [] to stop rotating (see Link Rotation below). Pro, Lifetime and Team only. |
curl -X PATCH https://www.flyn.to/api/links/a1b2c3d4-... \
-H "Authorization: Bearer flyn_sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "tags": ["marketing", "updated"], "status": "archived" }'Returns the updated Link object. Triggers link.update webhook event.
Delete a Link
Permanently deletes a link and all associated click data. This action cannot be undone.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The UUID of the link to delete |
Response (200)
{ "deleted": true, "id": "a1b2c3d4-..." }Triggers link.delete webhook event with the full link data that was deleted.
Smart Routing (Targeting)
The targeting field lets you redirect users to different destinations based on their device type or geographic location. Pass it as a JSON object when creating or updating a link.
{
"targeting": {
"device": {
"ios": "https://apps.apple.com/app/your-app",
"android": "https://play.google.com/store/apps/details?id=your.app",
"windows": "https://apps.microsoft.com/detail/your-app-id",
"macos": "https://apps.apple.com/app/your-mac-app"
},
"geo": {
"US": "https://yoursite.com/us",
"DE": "https://yoursite.com/de",
"JP": "https://yoursite.com/jp"
}
}
}The shape is enforced: anything else is refused with 400 and code: "INVALID_TARGETING", naming the keys it did not accept, rather than being stored as a rule that never fires. Device targeting takes four keys, ios, android, windows and macos, and all four live under device. windows and macos must be http or https URLs, such as a Microsoft Store, Mac App Store or download page. There is no desktop key, and mac, osx, win, desktop and linux are refused with a hint: Linux, ChromeOS and any other system match no device rule and fall through to the link's other rules. Geo targeting uses ISO 3166-1 alpha-2 country codes and sits at the top level, beside device. Device rules are evaluated first, then geo, then rotation when the link has one, then the default url.
Editing targeting
A PATCH replaces the whole targeting object; nothing is merged into the rules already stored. To add or change one rule, read the link first, edit its targeting and send the whole object back. Sending only a macos rule removes every other device and country rule. Send null or {} to remove them all.
Safari on Mac and iPad
Safari on an iPad usually sends the same user agent as Safari on a Mac, so the server alone cannot tell them apart. On a link with a macos rule, Safari on a Mac or an iPad first loads a blank check page that tells a Mac from an iPad, then continues at once. With JavaScript off the browser continues as a Mac. The check page records no click; the click is counted once, when the browser continues to the short link with the reserved query parameters flyn_os and flyn_ref, which are never passed to your 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.
Link Rotation
The rotation field turns one short link into a weighted split across up to 12 destinations. It is available on Pro, Lifetime and Team; a free account gets 403 with code: "UPGRADE_REQUIRED" and premiumFields: ["rotation"].
Destination Object
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | No | Stable identifier, recorded as the click variant on each click. The dashboard groups clicks by it in the "Destination Performance" panel on that link's analytics page; the API returns no ready-made breakdown. Omit on a new destination and Flyn mints one. Reuse the existing id when editing, see Editing a rotation below. |
| url | string | Yes | Destination URL for this entry. Checked with Google Safe Browsing, and rejected if it resolves to a private address. |
| weight | number | Yes | Relative share of traffic, not a percentage. A weight of 0 keeps the destination in the list without ever serving it. |
| label | string | No | Human-readable name for the destination. Cosmetic, it does not affect routing. |
curl -X POST https://www.flyn.to/api/links \
-H "Authorization: Bearer flyn_sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/offer-a",
"rotation": [
{ "url": "https://example.com/offer-a", "weight": 3, "label": "Offer A" },
{ "url": "https://example.com/offer-b", "weight": 1, "label": "Offer B" }
]
}'The response echoes the stored rotation with an id filled in on every destination:
"rotation": [
{ "id": "V1StGXR8", "url": "https://example.com/offer-a", "weight": 3, "label": "Offer A" },
{ "id": "Z_Rk7bQ2", "url": "https://example.com/offer-b", "weight": 1, "label": "Offer B" }
]How the split works
- Weights are relative, not percentages. They are normalised across the list, so
3and1send roughly three quarters of clicks to the first destination, and equal weights give an even split. - The destination is drawn per click in proportion to its weight, so the split settles over volume rather than following a strict repeating order.
- Maximum 12 destinations. Sending more returns a 400 rather than silently keeping the first twelve.
- If every weight is 0 there is nothing left to serve, so no rotation is stored and the link keeps serving the primary
url. - Bots and crawlers are never sampled into a rotation: they always receive the primary
url, which is why it stays required. - A matching
targetingrule wins. Rotation only applies to visitors that smart routing did not already place. - Every destination is safety scanned before the link is saved. A blocked one returns
422withcode: "DESTINATION_BLOCKED".
Editing a rotation
Each destination's id is what its clicks are recorded against, so it is the join key between a destination and its history. A PATCH replaces the whole list: read the link first, send every destination you are keeping back with the id it already has, and leave the id off only for genuinely new ones. Changing an id detaches that destination's past clicks, and reusing one merges two destinations' stats. Never derive ids from list position. Send an empty array to stop rotating and serve the single url again.
curl -X PATCH https://www.flyn.to/api/links/a1b2c3d4-... \
-H "Authorization: Bearer flyn_sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"rotation": [
{ "id": "V1StGXR8", "url": "https://example.com/offer-a", "weight": 3 },
{ "id": "Z_Rk7bQ2", "url": "https://example.com/offer-b", "weight": 1 },
{ "url": "https://example.com/offer-c", "weight": 1 }
]
}'A/B fields (compatibility view)
abUrlB and abSplit are the older two-destination form of the same idea, where abSplit is the percentage of traffic kept on the primary url. They remain accepted on create and update and are still returned on every link, so existing integrations keep working unchanged. Links that already used them were migrated into rotation under the ids A and B, so their click history stayed attached. When a link carries both, rotation is what the redirect serves. New integrations should use rotation: it is the full picture, and it is the only one of the two that goes past two destinations.
Slug Rules
- Allowed characters:
a-z A-Z 0-9 - _ - Maximum length: 100 characters
- Must be unique per domain (you can reuse slugs across different domains)
- Reserved slugs (e.g.
api,login,docs) cannot be used - If omitted, a random 6-character slug is generated using nanoid. How many links a code of that length can hold, and when random codes start to collide, is worked out in How Long Should a Short URL Be?
SDK Examples
const link = await flyn.links.create({
url: 'https://example.com/my-long-url',
slug: 'my-campaign',
domain: 'go.yourcompany.com',
tags: ['marketing', 'q1-2026'],
ogTitle: 'Check out our product',
});
console.log(link.shortUrl);
// → https://go.yourcompany.com/my-campaignasync function getUrlWithClicks() {
const res = await flyn.links.list({
status: 'active',
tag: 'marketing',
sort: 'clicks',
order: 'desc',
limit: 20,
});
res.data.forEach(l => console.log(l.shortUrl, l.clicks));
}
getUrlWithClicks();Was this page helpful? Spotted something wrong?