API reference

Create and manage short links from your own code.

Authentication

Every request needs a personal API key, sent as a bearer token. Create one on the API page — it's shown once and stored only as a hash, so if you lose it you'll need to create another.

Authorization header
Authorization: Bearer yorl_sk_a1b2c3...

Keep keys server-side. A key carries the same power over your links as your password, so never ship one in browser or mobile code where anyone can read it. Revoking a key takes effect immediately.

API access requires a paid plan. On the Free plan key creation is disabled and requests are rejected with 402 upgrade_required.

Quickstart

Shorten your first URL:

cURL
curl -X POST https://yorl.cc/api/v1/links \
  -H "Authorization: Bearer $YORL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"longUrl": "https://example.com/a-very-long-url"}'
Response · 201 Created
{
  "id": "5bba50a3-579c-4b61-a05f-40ca2be48fe7",
  "code": "aB3xK9p",
  "shortUrl": "https://yorl.cc/aB3xK9p",
  "longUrl": "https://example.com/a-very-long-url",
  "title": null
}

In JavaScript

Node.js
const response = await fetch("https://yorl.cc/api/v1/links", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.YORL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ longUrl: "https://example.com/page" }),
});

if (!response.ok) {
  const error = await response.json();
  throw new Error(`yorl: ${error.error}`);
}

const link = await response.json();
console.log(link.shortUrl);

In Python

Python
import os, requests

response = requests.post(
    "https://yorl.cc/api/v1/links",
    headers={"Authorization": f"Bearer {os.environ['YORL_API_KEY']}"},
    json={"longUrl": "https://example.com/page"},
    timeout=10,
)
response.raise_for_status()
print(response.json()["shortUrl"])

Create a link

POST/api/v1/links
Body parametersTypeDescription
longUrlstringrequiredThe destination. Must be http or https and at most 2048 characters.
customCodestringoptionalYour own short code — letters, digits, hyphens and underscores, 3–64 characters. Random if omitted.
titlestringoptionalA label for your dashboard. Not shown to visitors.
tagIdsstring[]optionalTag ids to apply. Up to 20.
utmobjectoptionalUTM parameters, applied to the destination at creation.
expiresAtstringoptionalISO 8601 timestamp. After this the link stops redirecting.

With every option

cURL
curl -X POST https://yorl.cc/api/v1/links \
  -H "Authorization: Bearer $YORL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "longUrl": "https://example.com/spring-sale",
    "customCode": "spring26",
    "title": "Spring campaign",
    "utm": {
      "source": "newsletter",
      "medium": "email",
      "campaign": "spring_2026"
    },
    "expiresAt": "2026-12-31T23:59:59.000Z"
  }'

UTM values are written into the destination when the link is created, so the stored longUrl comes back with them already applied. Existing query parameters and any fragment are preserved:

Response · longUrl
"longUrl": "https://example.com/spring-sale?utm_source=newsletter&utm_medium=email&utm_campaign=spring_2026"

Custom codes and premium options. customCode, utm and expiresAt need a paid plan. The API rejects them with 402 rather than ignoring them, so you never receive a link that quietly lacks the expiry you asked for.

List your links

GET/api/v1/links
Query parametersTypeDescription
qstringoptionalSearch across code, title and destination.
statusstringoptional`active` or `disabled`. Both if omitted.
tagIdstringoptionalOnly links carrying this tag.
limitnumberoptionalPage size, 1–100. Defaults to 20.
offsetnumberoptionalRows to skip. Defaults to 0.
cURL
curl "https://yorl.cc/api/v1/links?status=active&limit=50" \
  -H "Authorization: Bearer $YORL_API_KEY"
Response · 200 OK
{
  "total": 128,
  "limit": 50,
  "offset": 0,
  "links": [
    {
      "id": "5bba50a3-...",
      "code": "spring26",
      "shortUrl": "https://yorl.cc/spring26",
      "longUrl": "https://example.com/spring-sale",
      "title": "Spring campaign",
      "isActive": true,
      "clickCount": 1432,
      "createdAt": "2026-08-01T09:12:44.019Z",
      "tags": [{ "id": "d157c3ac-...", "name": "Campaign" }]
    }
  ]
}

total is the count matching your filters, not the page — use it to drive pagination.

Get, update and delete

GET/api/v1/links/{id}

Returns one link, including its tags and timestamps.

PATCH/api/v1/links/{id}

Send only the fields you want changed. Changing longUrl re-runs the safety check; the short code never changes, so links you have already shared keep working.

Pause a link and repoint it
curl -X PATCH https://yorl.cc/api/v1/links/$LINK_ID \
  -H "Authorization: Bearer $YORL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"longUrl": "https://example.com/new-page", "isActive": false}'
Body parametersTypeDescription
longUrlstringoptionalNew destination. Re-validated and safety-checked.
titlestring | nulloptional`null` clears it.
isActivebooleanoptional`false` shows a notice instead of redirecting.
tagIdsstring[]optionalReplaces the tag set entirely.
utmobject | nulloptional`null` strips UTM values from the destination.
expiresAtstring | nulloptional`null` removes the expiry.
DELETE/api/v1/links/{id}

Permanent. Anyone following the link afterwards gets a 404, and the code becomes available again. To keep a code reserved, set isActive: false instead.

Analytics

GET/api/v1/links/{id}/analytics
Query parametersTypeDescription
rangestringoptional`7d`, `30d` or `90d`. Defaults to `30d`.
cURL
curl "https://yorl.cc/api/v1/links/$LINK_ID/analytics?range=7d" \
  -H "Authorization: Bearer $YORL_API_KEY"
Response · 200 OK
{
  "range": "7d",
  "lifetimeClicks": 1432,
  "total": 88,
  "timeseries": [
    { "date": "2026-08-22", "clicks": 12 },
    { "date": "2026-08-23", "clicks": 0 }
  ],
  "countries": [{ "label": "IN", "clicks": 54 }],
  "referrers": [{ "label": "twitter.com", "clicks": 31 }],
  "devices": [{ "label": "mobile", "clicks": 61 }],
  "browsers": [{ "label": "Chrome", "clicks": 44 }]
}

timeseries always covers the full window, emitting zero-click days, so you can plot it without filling gaps yourself. lifetimeClicks is the all-time counter and can exceed total, which is windowed. Breakdowns are ranked by clicks, and dimensions we could not determine are grouped under "Unknown".

Bulk actions

POST/api/v1/links/bulk
Disable several links at once
curl -X POST https://yorl.cc/api/v1/links/bulk \
  -H "Authorization: Bearer $YORL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"linkIds": ["id-1", "id-2"], "action": "disable"}'
Body parametersTypeDescription
linkIdsstring[]required1–100 link ids.
actionstringrequired`enable`, `disable` or `delete`.
Response · 200 OK
{ "affected": 2 }

affected counts rows actually changed, which may be fewer than you sent: ids you don't own simply match nothing rather than erroring.

Tags

GET/api/v1/tags
POST/api/v1/tags
DELETE/api/v1/tags/{id}
Create a tag
curl -X POST https://yorl.cc/api/v1/tags \
  -H "Authorization: Bearer $YORL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Q4 campaign", "color": "#f4183f"}'

Tag names are unique per account, case-insensitively — a duplicate returns 409 tag_exists. Deleting a tag removes it from links but never deletes the links.

Errors

Errors return a JSON body with a stable error code. Branch on that code, not on the human-readable message, which may change.

Error shape
{
  "error": "link_limit_reached",
  "currentPlan": "starter",
  "used": 100,
  "limit": 100,
  "message": "You have used all 100 links on the Starter plan."
}
StatusCodeWhat it means
400invalid_requestThe body failed validation. `issues` lists what.
401invalid_api_keyThe key is wrong or has been revoked.
401unauthorizedNo credential was presented.
402upgrade_requiredYour plan does not include that feature.
402link_limit_reachedYou are at your plan's link limit.
404not_foundNo such link — or it belongs to someone else.
409code_takenThat custom code is already in use.
409tag_existsYou already have a tag with that name.
422unsafe_urlThe destination failed the safety check.
422invalid_custom_codeThe code is malformed or reserved.
422invalid_expiryThe expiry is in the past or malformed.
429rate_limitedToo many requests. See `Retry-After`.
503code_generation_exhaustedCould not allocate a code. Retry.

Why 404 and not 403. A link belonging to another account returns 404, identical to one that doesn't exist. Distinguishing them would let anyone probe which codes are in use.

Rate limits

Link creation is limited per account. Exceeding it returns 429 with a Retry-After header in seconds — wait that long rather than retrying immediately.

Handling 429 with backoff
async function createLink(longUrl, attempt = 0) {
  const response = await fetch("https://yorl.cc/api/v1/links", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.YORL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ longUrl }),
  });

  if (response.status === 429 && attempt < 3) {
    const wait = Number(response.headers.get("Retry-After") ?? 60);
    await new Promise((r) => setTimeout(r, wait * 1000));
    return createLink(longUrl, attempt + 1);
  }

  if (!response.ok) throw new Error((await response.json()).error);
  return response.json();
}

Plan limits

PlanLinksAPI access
Free50
Starter100Yes
Scale3,000Yes
EnterpriseUnlimitedYes

Something missing from these docs? Email [email protected].