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: 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 -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"}'{
"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
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
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
/api/v1/links| Body parameters | Type | Description | |
|---|---|---|---|
| longUrl | string | required | The destination. Must be http or https and at most 2048 characters. |
| customCode | string | optional | Your own short code — letters, digits, hyphens and underscores, 3–64 characters. Random if omitted. |
| title | string | optional | A label for your dashboard. Not shown to visitors. |
| tagIds | string[] | optional | Tag ids to apply. Up to 20. |
| utm | object | optional | UTM parameters, applied to the destination at creation. |
| expiresAt | string | optional | ISO 8601 timestamp. After this the link stops redirecting. |
With every option
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:
"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
/api/v1/links| Query parameters | Type | Description | |
|---|---|---|---|
| q | string | optional | Search across code, title and destination. |
| status | string | optional | `active` or `disabled`. Both if omitted. |
| tagId | string | optional | Only links carrying this tag. |
| limit | number | optional | Page size, 1–100. Defaults to 20. |
| offset | number | optional | Rows to skip. Defaults to 0. |
curl "https://yorl.cc/api/v1/links?status=active&limit=50" \
-H "Authorization: Bearer $YORL_API_KEY"{
"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
/api/v1/links/{id}Returns one link, including its tags and timestamps.
/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.
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 parameters | Type | Description | |
|---|---|---|---|
| longUrl | string | optional | New destination. Re-validated and safety-checked. |
| title | string | null | optional | `null` clears it. |
| isActive | boolean | optional | `false` shows a notice instead of redirecting. |
| tagIds | string[] | optional | Replaces the tag set entirely. |
| utm | object | null | optional | `null` strips UTM values from the destination. |
| expiresAt | string | null | optional | `null` removes the expiry. |
/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
/api/v1/links/{id}/analytics| Query parameters | Type | Description | |
|---|---|---|---|
| range | string | optional | `7d`, `30d` or `90d`. Defaults to `30d`. |
curl "https://yorl.cc/api/v1/links/$LINK_ID/analytics?range=7d" \
-H "Authorization: Bearer $YORL_API_KEY"{
"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
/api/v1/links/bulkcurl -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 parameters | Type | Description | |
|---|---|---|---|
| linkIds | string[] | required | 1–100 link ids. |
| action | string | required | `enable`, `disable` or `delete`. |
{ "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.
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": "link_limit_reached",
"currentPlan": "starter",
"used": 100,
"limit": 100,
"message": "You have used all 100 links on the Starter plan."
}| Status | Code | What it means |
|---|---|---|
| 400 | invalid_request | The body failed validation. `issues` lists what. |
| 401 | invalid_api_key | The key is wrong or has been revoked. |
| 401 | unauthorized | No credential was presented. |
| 402 | upgrade_required | Your plan does not include that feature. |
| 402 | link_limit_reached | You are at your plan's link limit. |
| 404 | not_found | No such link — or it belongs to someone else. |
| 409 | code_taken | That custom code is already in use. |
| 409 | tag_exists | You already have a tag with that name. |
| 422 | unsafe_url | The destination failed the safety check. |
| 422 | invalid_custom_code | The code is malformed or reserved. |
| 422 | invalid_expiry | The expiry is in the past or malformed. |
| 429 | rate_limited | Too many requests. See `Retry-After`. |
| 503 | code_generation_exhausted | Could 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.
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
| Plan | Links | API access |
|---|---|---|
| Free | 50 | — |
| Starter | 100 | Yes |
| Scale | 3,000 | Yes |
| Enterprise | Unlimited | Yes |
Something missing from these docs? Email [email protected].