# esyurl > Agent-first API for short links. A link has a stable short URL > (https://esyurl.fyi/{slug}) that 302-redirects to its targetUrl; change targetUrl at > any time and shared URLs and printed QR codes keep working. Any link can be > rendered as a QR code, plain or styled (module shapes, corner markers, a logo > or a halftone photo). Visits are counted per source (qr, direct, bot), and > links can be organised into groups. ## Authentication Every /v1 route and /mcp needs an API key (issued by an operator, or by self-sign-up): Authorization: Bearer esy__ # or x-api-key: esy__ Scopes: links:read (list/get/qr/visits/render, read groups), links:write (create/update/delete links and groups, change membership). Keys are bound to one tenant; you only ever see your tenant's links and groups. No key? Sign yourself up, no human needed: follow https://esyurl.fyi/auth.md (POST https://esyurl.fyi/agent/identity {"type":"anonymous"}, then exchange the identity_assertion at https://esyurl.fyi/oauth2/token for an esy_ API key). A 401 carries WWW-Authenticate: Bearer resource_metadata="https://esyurl.fyi/.well-known/oauth-protected-resource". Self-signed-up tenants have a link quota (403 quota_exceeded); a human can claim the tenant to raise it (POST /v1/account/claim {"email"} with your key, or the claim_account MCP tool, when claims are enabled; GET /v1/me shows ownerEmail). ## MCP POST https://esyurl.fyi/mcp — Streamable HTTP, stateless, same API key. initialize and tools/list answer without one; tool calls need it. MCP clients that do OAuth can sign in instead: /oauth2/register, /oauth2/authorize (PKCE S256), /oauth2/token. Links: create_short_link, list_short_links, get_short_link, update_short_link, delete_short_link, get_short_link_visits, render_qr_code (PNG image; by linkId or arbitrary data, styles included; plus PNG and SVG download URLs, /qr/{token}.png|svg, that need no key for an hour, so an MCP-only agent can save the file; not for data with an image), test_redirect_rules (dry run of a link's redirect rules), preview_link_page (a link page as HTML for a simulated visit), set_domain_home_page. Groups: create_group, list_groups, get_group, update_group, delete_group, add_links_to_group, remove_link_from_group, get_group_visits. Custom domain: get_custom_domain, set_custom_domain, remove_custom_domain. Account: get_account, claim_account (hand a sign-up account to a human by email). ## REST quick reference Links - GET /v1/me — who am I, which tenant - POST /v1/links — {"targetUrl": "https://..." or "page": {..} (see Link pages), "name"?, "slug"?, "tags"?: [..], "metadata"?: {..}, "qrStyle"?: {..} (see QR styles), "groupIds"?: [..], "rules"?: [..] (see Redirect rules)} - GET /v1/links?limit=20&cursor=...&tag=...&group=... — newest first; follow nextCursor until absent (pages may be short) - GET /v1/links/{id} - PATCH /v1/links/{id} — any of targetUrl, page (replaces the page; null + a targetUrl turns it back into a redirect), name, tags, metadata, qrStyle, status ("active" | "paused"), groupIds (replaces the set), rules (replaces the list; [] removes all) - DELETE /v1/links/{id} — 204; also leaves its groups - GET /v1/links/{id}/qr?format=png|svg&size=64..2048&margin=0..16&dark=000000&light=ffffff&ecl=L|M|Q|H — encodes {shortUrl}?s=qr - GET /v1/links/{id}/visits?days=30&recent=50 — totals {visits, qr, direct, bot}, daily (UTC), recent events newest first, byRule {fallback, rules: [{ruleId, name, current, visits}]}, and for link pages byItem {clicks, items: [{itemId, label, current, clicks}]} - POST /v1/links/{id}/resolve — dry run of the rules: {"userAgent"?, "acceptLanguage"?, "referrer"?, "country"?: "GB", "query"?: {..}, "source"?: "qr"|"direct", "at"?: ISO} → {targetUrl, matchedRule?, status, context} (a link page with no matching rule: {page: {itemIds}} instead of targetUrl); records nothing - POST /v1/links/{id}/page/preview — {"page"?: a draft, "visit"?: same as resolve} → the page as text/html (header X-Visible-Items); records nothing, stores nothing - POST /v1/render — {"data": "any text", "format"?: "png"|"svg", "size"?, "margin"?, "dark"?, "light"?, "ecl"?, plus the QR style fields and "image"?: {"mode", "data", "size"?}} ad-hoc static image, nothing saved or tracked Groups - POST /v1/groups — {"name", "description"?, "color"?} - GET /v1/groups?limit&cursor - GET | PATCH | DELETE /v1/groups/{id} — delete keeps the links, removes memberships - GET /v1/groups/{id}/links?limit&cursor - POST /v1/groups/{id}/links — {"linkIds": [..up to 25]} idempotent; returns {group, added, alreadyMembers} - DELETE /v1/groups/{id}/links/{linkId} - GET /v1/groups/{id}/visits?days=30 — summed over current members Custom domain (one alias per tenant, e.g. go.acme.com) - GET /v1/domain — status + the exact DNS records to create; each call advances provisioning - PUT /v1/domain — {"domain": "go.acme.com"} sets or replaces the alias - DELETE /v1/domain — 204 Lifecycle: pending_validation (create the validation CNAME) → provisioning → pending_dns (CNAME your domain to the given target) → active. Once active, shortUrl and QR images use https://{domain}/{slug}; the platform URLs keep working. Only your links resolve there. - GET | PUT | DELETE /v1/home — {"linkId"}: the link the root (/) of your custom domain shows (a link page becomes its home page) Public - GET /{slug} (and /r/{slug}) — 302 to the first matching rule's targetUrl, else the link's targetUrl, or its page (200 HTML); query string is not forwarded - GET /{slug}/{itemId} — a link page's button: counts the click, 302 to its url - GET /{slug}/i/{imageId}.png — a link page's pictures Full schema: https://esyurl.fyi/openapi.json ## QR styles Free on every link (qrStyle, used by GET /v1/links/{id}/qr and render_qr_code) and on POST /v1/render. All optional: - "dark", "light": hex colours. "errorCorrectionLevel": L|M|Q|H. "margin": 0-16. - "modules": square | rounded | dot | row | column (shape of the data modules). - "eyeFrame", "eyeBall": square | rounded | circle (the three corner markers, outer ring and centre). "eyeColor": hex, defaults to dark. - "image": a picture. {"mode": "logo", "data": "data:image/png;base64,...", "size"?: 0.1-0.3} puts it in the middle (error correction becomes H; size is its side as a share of the code, default 0.22). {"mode": "halftone", "data": ...} puts a photo behind the code and draws the code as dots over it. png, jpeg, webp or gif, up to 2 MB; it is stored with the link (512 px) until you remove it or the link. Responses show "image": {"mode", "imageId"}; to change other fields and keep the picture, send that imageId back instead of data. Styled codes need dark on light at a contrast of at least 3:1 (400 otherwise). Styles keep what scanners need (solid corner markers, small logos, error correction H with pictures); the whole qrStyle is replaced on PATCH. Example: {"qrStyle": {"modules": "dot", "eyeFrame": "rounded", "eyeBall": "circle", "dark": "#0a1630"}} No API key at all? POST https://esyurl.fyi/x402/qr takes the same body as POST /v1/render (styles and pictures included) and costs 0.01 USD in USDC per image, paid with x402 the same way: no payment → 402, repeat with PAYMENT-SIGNATURE → the image plus PAYMENT-RESPONSE. GET https://esyurl.fyi/x402/qr?data=...&modules=dot does the same with query parameters. With a key, /v1/render and link QR codes are free. ## Link pages A link can show a page instead of redirecting: a link in bio. Send "page" instead of "targetUrl". Same slug, QR code, visits, groups and custom domain. Rules still run first: a matching rule redirects, otherwise the page shows. POST /v1/links {"slug": "maria", "page": { "title": "Maria Silva", "bio": "Ceramics from Porto", "avatar": {"data": "data:image/png;base64,..."}, "items": [ {"type": "link", "label": "Shop", "url": "https://shop.example.com", "highlight": true}, {"type": "link", "label": "Newsletter", "linkId": ""}, {"type": "header", "text": "Workshops"}, {"type": "link", "label": "App Store", "url": "https://apps.apple.com/app/id1", "when": {"field": "os", "op": "eq", "value": "ios"}}, {"type": "text", "text": "Open Tue-Sat, 10:00-18:00"}], "socials": [{"platform": "instagram", "url": "https://instagram.com/maria"}, {"platform": "email", "url": "mailto:maria@example.com"}], "theme": {"preset": "sunset", "button": {"shape": "pill"}}}} - page: title (≤80), bio? (≤300), avatar?, items (≤50), socials? (≤12), theme?, language? ("pt-BR"), noindex?, footer?. The whole page is replaced on PATCH. - Items: {"type": "link", "label", "url"} (clicks counted, then 302 to url), {"type": "link", "label", "linkId"} (opens that link's short URL: its rules and visits apply), {"type": "header", "text"}, {"type": "text", "text"}. Each takes "id"? (generated; clicks are counted by it, so keep it when editing), "enabled"? (default true) and "when"? (a condition in the redirect-rule grammar below: shown only to matching visits). Link items take "highlight"? and "thumbnail"? (a picture). - Pictures (avatar, thumbnails, background): {"data": "data:image/...;base64,..."} (png, jpeg, webp or gif, up to 2 MB), cropped square and stored with the link; responses show {"imageId"}, which you send back to keep a picture. ≤ 8 per page. - socials platforms: instagram, tiktok, youtube, x, facebook, linkedin, github, threads, bluesky, mastodon, whatsapp, telegram, spotify, twitch, discord, pinterest, snapchat, substack, email (mailto:), website. - theme (all optional): "preset": light | dark | midnight | sunset | forest | pastel | mono; "background": {"type": "solid", "color"} | {"type": "gradient", "from", "to", "angle"?} | {"type": "image", "image": {"data"}, "dim"?: 0-0.8}; "text": hex; "font": system | rounded | serif | mono | condensed; "button": {"shape": square | rounded | pill, "fill": solid | outline | soft | shadow, "color", "text"}; "avatarShape": circle | rounded | square; "align": center | left. - Colours that read badly (below 4.5:1) come back in "warnings" on the response; the page is saved anyway. - The page is plain HTML: no scripts, forms or custom CSS, and a strict Content-Security-Policy. Pages that read like phishing (a brand next to "login", "password", "seed phrase"...) are refused for unclaimed self-signed-up tenants and flagged in "warnings" otherwise. Pages of unclaimed tenants are noindex. Link pages of self-signed-up tenants carry a small "Made with esyURL · Report" footer. Removing it costs 3.00 USDC on Base, once per page, for good (it survives edits, and turning the page into a redirect and back): send "footer": false in the page on POST /v1/links or PATCH /v1/links/{id}; the call answers 402 with the PaymentRequired, and you repeat it with PAYMENT-SIGNATURE (MCP: _meta["x402/payment"] or the "payment" argument of create_short_link / update_short_link). Invalid input is refused before any payment. If settlement fails, a new link is not created and an updated page keeps its footer. ## Redirect rules A link's "rules" is an ordered list; on each visit the first enabled rule whose "when" matches picks the destination, otherwise the link's targetUrl is used (so targetUrl is always the fallback). Rule: {"id"?, "name"?, "enabled"?: true, "when": , "targetUrl"}. Ids are generated when omitted; keep them when editing, visit stats are keyed by them. Condition: {"field", "op", "value"} or {"all": [..]}, {"any": [..]}, {"not": {..}}. | field | values | ops | |----------|----------------------------------------------------------|-----| | os | ios, android, windows, macos, linux, chromeos, other | eq neq in not_in | | device | mobile, tablet, desktop, bot | eq neq in not_in | | browser | safari, chrome, firefox, edge, samsung, opera, in_app, other | eq neq in not_in | | source | qr, direct, bot | eq neq in not_in | | language | Accept-Language primary tag; "pt" also matches "pt-BR" | eq neq in not_in exists | | country | ISO 3166-1 alpha-2, e.g. "GB"; see below | eq neq in not_in | | referrer | referring host, e.g. "l.instagram.com" | eq neq in not_in contains starts_with ends_with exists | | query | a short-URL query parameter; needs "key" | eq neq in not_in contains starts_with ends_with exists | | time | ISO 8601 timestamp | before after | | weekday | sun mon tue wed thu fri sat (UTC) | eq neq in not_in | | hour | 0-23 (UTC) | eq neq in not_in gte lte | in/not_in take an array; text compares case-insensitively. At most 20 rules and 20 conditions per rule, nested 4 deep. iPadOS Safari identifies as macOS, so iPads may read as macos/desktop. An absent value (no Referer, no language) fails eq/in and passes neq/not_in. country is the visitor's country as the edge geolocates it, on every host (custom domains included). When it can't be told (rarely: some networks, Tor), it is unknown, and unknown behaves like any absent value. App-store split (one QR code for both stores, website for everyone else): PATCH /v1/links/{id} {"targetUrl": "https://example.com/app", "rules": [ {"name": "iOS", "when": {"field": "os", "op": "eq", "value": "ios"}, "targetUrl": "https://apps.apple.com/app/id123456789"}, {"name": "Android", "when": {"field": "os", "op": "eq", "value": "android"}, "targetUrl": "https://play.google.com/store/apps/details?id=com.example.app"}]} More: {"all": [{"field": "language", "op": "eq", "value": "pt"}, {"field": "device", "op": "eq", "value": "mobile"}]}, {"field": "time", "op": "after", "value": "2026-12-01T00:00:00Z"} (launch day switch), {"field": "query", "key": "utm_source", "op": "eq", "value": "newsletter"}. Check with POST /v1/links/{id}/resolve (or test_redirect_rules) before sharing. ## Visits - Every GET of a short URL is a visit; HEAD is not. - ?s=qr → source "qr" (QR images encode this); otherwise "direct". - Link-preview fetchers and crawlers (Slackbot, facebookexternalhit, Twitterbot, LinkedInBot, WhatsApp, TelegramBot, Discordbot, Googlebot, bingbot, ...) are "bot": counted in their own bucket, excluded from visits and visitCount. curl and HTTP libraries count as direct. - Individual visit events are kept for 90 days; daily counters indefinitely. - Visits a redirect rule decided carry its ruleId (events) and are counted per rule (daily byRule, and byRule in the visits response; fallback = went to targetUrl). Bots are evaluated against rules too but, as ever, not counted as visits. ## Pricing Everything is free, styled QR codes and link pages included, except custom domains (and removing a link page's footer, see Link pages) for self-signed-up tenants: setting up a new domain costs 5.00 USDC on Base, once per domain, paid with x402 (https://x402.org, protocol v2, "exact" scheme, USDC). Tenants whose key came from an operator never pay. Reading, re-setting the same domain and removing it are free; replacing it with another domain is a new payment. HTTP: 1. PUT https://esyurl.fyi/v1/domain {"domain": "go.acme.com"} → 402. The body (and the base64 PAYMENT-REQUIRED header) is a PaymentRequired: {"x402Version": 2, "accepts": [{"scheme": "exact", "network", "amount", "asset", "payTo", ...}]}. Invalid or taken domains fail with 400/409 first; nothing is charged for them. 2. Sign the payment with an x402 client (EIP-3009 transferWithAuthorization for accepts[0]) and repeat the PUT with the base64 PaymentPayload in the PAYMENT-SIGNATURE header (X-PAYMENT also accepted). 3. 200 with the domain, plus a base64 PAYMENT-RESPONSE header {"success": true, "transaction": "0x...", "network", "payer"}. If settlement fails the domain is not set up and you get 402 again. A payment can be used once; 503 payments_not_configured means payments are not switched on yet (nothing is charged). Without an API key the PUT answers 402 too, with WWW-Authenticate: that is the price; get a key (auth.md) before paying, since a payment without a key is refused (401). MCP (x402 MCP transport): set_custom_domain without payment returns isError with the PaymentRequired as structuredContent; call it again with the PaymentPayload in params._meta["x402/payment"] (or, if your client can't set _meta, in the "payment" argument). The settlement is in result._meta["x402/payment-response"]. ## Rules and limits - targetUrl must be an absolute http(s) URL. A link has a targetUrl or a page, never both. - slug: ^[a-zA-Z0-9_-]{3,64}$, globally unique, immutable; random 8 chars if omitted. 409 if taken. Route names are reserved (health, v1, mcp, r, api, admin, www, robots, llms, openapi, ...): 400. - tags ≤ 20; metadata ≤ 20 string→string pairs; a link is in ≤ 20 groups; colours are hex (#rgb, #rrggbb, #rrggbbaa; "#" optional). - groupIds must be your own groups (404 otherwise). - Paused links answer with 410; deleted or unknown ones with 404. - Errors are JSON: {"error": {"code": "validation_error", "message": "...", "details"?: [...]}}. ## Typical flow 1. POST /v1/groups {"name": "Spring campaign"} (optional). 2. POST /v1/links with the destination URL (and groupIds). 3. Share shortUrl, and/or GET /v1/links/{id}/qr?format=svg for a printable QR code. 4. Later, PATCH targetUrl to redirect everything already shared; GET .../visits to see traffic.