Developers

URL shortener API

Links, custom domains and click statistics in Xlyl are all available over a JSON REST API, so you can shorten links from your own application, a CI pipeline or a bot.

API reference: links, domains and statistics over JSON

API key and authentication

Sign in, open API key and generate a key. The key is shown once, at the moment it is created — only a hash of it is stored, so it cannot be recovered later. If you lose it, generate another, which revokes the previous one immediately. The quick-start samples on that page are filled in with your new key, so the first request is a copy and paste.

The API key page: a freshly issued key, and curl and JavaScript samples that already contain it

Treat it like a password. It authenticates as your account, so keep it server-side and never ship it in client-side JavaScript. Send it in an X-API-Key header:

curl https://xlyl.link/api/v2/users \
  -H "X-API-Key: YOUR_API_KEY"

A token from POST /api/v2/auth/login also works, as Authorization: Bearer <token>. The web interface uses an httpOnly session cookie instead.

A wrong or revoked key is refused with 401 on every endpoint. POST /links can also be called with no key at all, to create an anonymous link where this site allows it; such requests may have to pass a CAPTCHA and wait out a cooldown between links from the same address, so use a key for anything automated. Send a key and it has to be valid. GET /users, above, is a quick way to check one.

Endpoints

Every path is relative to https://xlyl.link/api/v2. Requests and responses are JSON (send Content-Type: application/json with a body), and every :id is the UUID the API returned as id.

MethodPathDoes
POST/linksCreate a link
GET/linksList, search and sort your links
PATCH/links/:idEdit a link
DELETE/links/:idDelete a link
GET/links/:id/statsClick totals for one link
GET/links/statsClick totals for the whole account, last 30 days
GET/links/:id/insightsPer-click breakdowns and unique visitors
GET/links/:id/visitsThe most recent clicks
GET/links/:id/visits.csvEvery click in a period, as CSV
POST/domainsAdd a custom domain
GET/domains/:id/checkCheck whether a domain works
DELETE/domains/:idRemove a domain from your account
GET/usersYour account, totals and domains

Statistics

Clicks are counted for links made with an account; bots are left out by their user agent. Unique visitors are an estimate from a daily-salted hash, not a count of people.

Totals for the account

GET /links/stats returns the same four periods across all of your links, without the link fields. It is built from the last 30 days only — so allTime here covers those 30 days — and is cached for up to 30 minutes.

Insights

GET /links/:id/insights?window=7d reads the per-click detail. window is 24h, 7d (the default), 30d or 90d.

{
  "window": "7d",
  "totals": { "clicks": 214, "visitors": 180 },
  "series": [{ "at": "2026-09-17T00:00:00.000Z", "clicks": 30, "visitors": 26 }, …],
  "breakdowns": {
    "country": [{ "name": "GB", "count": 90 }, …, { "name": "Other", "count": 12 }],
    "utm_campaign": [{ "name": "spring", "count": 120 }],
    …
  },
  "filtered": [{ "name": "bot", "count": 41 }, { "name": "repeat", "count": 9 }],
  "id": "6f1c0b52-…", "address": "spring-sale", …
}

breakdowns has country, region, city, device, browser, browser_version, os, os_version, referrer, referrer_url, channel, language, app (the app the link was opened in, when known) and the five utm_* tags; each lists its top six values, with the rest folded into Other. Per-click detail is kept for 365 days.

Every number above counts people only. filtered counts the clicks left out, by reason: bot (a self-declared crawler, preview fetcher or script), preview (a prefetch or HEAD request), not-a-page (fetched or embedded rather than opened), no-language (no Accept-Language), spoofed (a browser string without that browser's headers), datacenter (a cloud server) and repeat (the same visitor within 30 seconds). At most 1,000 filtered clicks are kept per link per day, so on a link under heavy bot traffic it is a lower bound.

Recent clicks

GET /links/:id/visits?limit=50 returns the newest clicks, 50 by default and at most 200, as { "visits": [...] }. Each has created_at, country, city, device, browser, os, referrer_host, app, channel, utm_source, utm_campaign and is_unique. Filtered clicks are not included.

Export clicks as CSV

curl -o clicks.csv "https://xlyl.link/api/v2/links/LINK_ID/visits.csv?days=365" \
  -H "X-API-Key: YOUR_API_KEY"

days is 30 by default and at most 365, and one file holds at most 50,000 clicks, newest first. Columns: time, location (country, region, city), device, browser and OS with versions, channel, referrer host and full referrer URL, the five UTM tags, language, app, is_unique and filtered_reason. Filtered clicks are included so the filter can be checked; an empty filtered_reason is a counted click. It can only go as far back as per-click detail is kept (365 days).

Domains

Your domains, each with its id, are listed in GET /users; there is no separate list endpoint.

Add a domain

curl -X POST https://xlyl.link/api/v2/domains \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"address": "go.example.com", "homepage": "https://example.com"}'

address is required, 3–64 characters; a leading www. is dropped. It cannot be xlyl.link or a domain already on an account. homepage, optional, is where visitors are sent when they open the domain itself or an address on it with no link. Returns 201:

{
  "id": "0b7e41d2-…",
  "address": "go.example.com",
  "homepage": "https://example.com",
  "check_status": null,
  "checked_at": null,
  "created_at": "2026-09-23T10:00:00.000Z",
  "updated_at": "2026-09-23T10:00:00.000Z"
}

Adding the domain does not set up its DNS; the custom domain guide covers that.

Check a domain

GET /domains/:id/check requests the domain itself, reads its DNS to explain any failure, and stores the outcome:

{
  "address": "go.example.com",
  "status": "working",
  "ok": true,
  "expected": { "ip": "…", "cname": null },
  "a": ["…"],
  "cname": [],
  "error": null,
  "checkedAt": "2026-09-23T10:05:00.000Z"
}
statusMeaning
workingThe domain reaches this server over HTTPS.
certificateIt reaches this server, but its HTTPS certificate is not valid.
no_httpsIt reaches this server over plain HTTP only.
not_servedDNS points here, but the server does not answer for it.
cloudflareIt resolves to Cloudflare and does not reach this server.
elsewhereIt resolves to some other address.
unreachableRecords exist, but whether they point here cannot be told.
missingThere is no A or CNAME record.

Remove a domain

DELETE /domains/:id removes the domain from your account and returns {"message": "Domain deleted successfully"}.

Account

GET /users returns the account behind the key:

{
  "email": "you@example.com",
  "verified": true,
  "has_apikey": true,
  "created_at": "2026-01-10T09:00:00.000Z",
  "links": 132,
  "clicks": 5210,
  "last_link_at": "2026-09-23T10:00:00.000Z",
  "domains": [ { "id": "0b7e41d2-…", "address": "go.example.com", … } ]
}

The key itself is never returned — only whether one exists.

Rate limits and errors

Creating links is limited per account to 60 requests per 15 minutes, and to 50 new links in any rolling 24 hours. Every POST /links request counts towards the first, including ones refused for a validation error. Apart from POST /auth/login, which is limited per address against password guessing, the other endpoints on this page are not rate limited.

Responses to POST /links carry the current state in RateLimit-Policy (limit;w=seconds) and RateLimit (limit=…, remaining=…, reset=seconds). Past the limit the answer is 429 with a Retry-After header in seconds. The daily limit also answers 429, but without Retry-After: wait for older links to age out of the 24 hours rather than retrying.

Errors are JSON, {"error": "…"}, and use conventional status codes:

StatusMeaning
400Validation failed — the message says which field, for example Custom URL is already in use. Also returned for a key whose account has not verified its email address yet.
401Missing or invalid API key
403The account has been banned
404No such link or domain on your account
429Rate limited, or the daily link limit reached
503A service the API depends on is briefly unavailable; retry after a pause

Every response has an X-Request-Id header, and unexpected failures (500, 503) repeat it as requestId in the body. Quote it when reporting a problem — it matches the entry in the server logs.

What people build with it

The common patterns, in rough order of how often they come up:

  • A link per record. One short link per customer, order or property, created when the record is, so every outbound message carries a trackable branded link.
  • Campaign scaffolding. A spreadsheet of placements in, a short link per placement out, each with its UTM-tagged destination and a description naming the campaign — the method is here.
  • CMS integration. Publishing a post creates its share link on your own domain, so the link exists before anyone needs it.
  • Reporting. Pulling per-link statistics into a warehouse or a dashboard beside the rest of your numbers.

All of it runs on your own domain if you have connected one, and the links behave exactly like ones made in the dashboard — editable, measurable and revocable.

Frequently asked questions

How do I authenticate with the API?
Send your key in an X-API-Key header on every request. Generate the key on the API key page; it is shown once and only a hash of it is stored, so copy it when it appears.
Can I create links in bulk?
Yes — one request per link, from a script, a spreadsheet or your own system. There is no bulk upload form; the script on this page creates links from a list, a few at a time, and waits when it is rate limited.
Can I create links on my custom domain through the API?
Yes. Put the domain's address, such as go.example.com, in the domain field alongside the target and alias. The domain has to be connected to your account first.
What happens if I lose my API key?
Generate a new one. Only a hash is stored, so the old key cannot be recovered — and regenerating revokes it immediately, so anything still using it gets 401.

Keep reading