# 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. 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. ## MCP POST https://esyurl.fyi/mcp — Streamable HTTP, stateless, same API key. 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), test_redirect_rules (dry run of a link's redirect rules). 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. ## REST quick reference Links - GET /v1/me — who am I, which tenant - POST /v1/links — {"targetUrl": "https://...", "name"?, "slug"?, "tags"?: [..], "metadata"?: {..}, "qrStyle"?: {"dark","light","errorCorrectionLevel","margin"}, "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, 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}]} - 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}; records nothing - POST /v1/render — {"data": "any text", "format"?: "png"|"svg", ...} 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. Public - GET /{slug} (and /r/{slug}) — 302 to the first matching rule's targetUrl, else the link's targetUrl; query string is not forwarded Full schema: https://esyurl.fyi/openapi.json ## 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 CloudFront geolocates it, so it is only known on short URLs served through CloudFront (https://esyurl.fyi); on other hosts (the platform's older address, custom domains) it is always 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 except custom domains 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). 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. - 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.