URL Shortener API
Create and manage short links from your own code. A REST API with API key authentication, JSON responses and per-plan rate limits.
Quick start
Three steps from nothing to your first short link. Everything on this page is a live endpoint you can call right now.
Base URL
https://urlcut.ai/api/v1- Create a key. Go to Account, then API keys, name it after the thing that will use it, and confirm your password. The key is shown once and cannot be recovered, so copy it before you leave the page.
- Send it as a bearer token on every request.
- Create your first link with the call below.
curl -X POST https://urlcut.ai/api/v1/links \
-H "Authorization: Bearer urlc_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{"destinationUrl": "https://example.com/spring-campaign", "shortCode": "early-bird"}'Authentication
Every request carries your API key in the Authorization header, as a
bearer token. There is no other way to authenticate, and there are no query-string
keys: a key in a URL ends up in browser history, proxy logs and referrer headers.
Authorization: Bearer urlc_46bf8fb5a3aa_SW3m6vxSvufhwWMwGfBOp3LYYzYyeUoKE4BJHpThPQWhat a key can and cannot do
A key carries your own ordinary permissions and nothing more. It can reach the endpoints on this page. It cannot reach the dashboard's own endpoints, it is never an admin credential even if your account is an admin, and it cannot create or revoke API keys. That last one is deliberate: a leaked key cannot quietly mint itself a replacement that survives you revoking the original.
Keeping a key safe
- Store it as an environment variable or in a secret manager, never in source control.
- Give each service its own key, so revoking one does not stop the others.
- If a key leaks, revoke it from Account, then API keys. Revocation is immediate and needs no password, because making a compromised key harder to remove than to create would be the wrong way round.
Endpoints
All paths are relative to https://urlcut.ai/api/v1. Requests and responses
are JSON. You can only see and change your own links: a link belonging to someone else
returns 404, exactly as a link that does not exist does.
Creates a short link.
Body
| Field | Type | Notes |
|---|---|---|
destinationUrl | string | Required. Where the link sends people. |
shortCode | string | Your own slug. Omit it and one is generated. |
title | string | For your own reference. |
description | string | For your own reference. |
tags | string[] | For filtering in the dashboard. |
expiresInDays | int | One of 7, 14, 30, 90, or -1 for never. |
Response 201 Created
{
"id": 1042,
"shortCode": "early-bird",
"shortUrl": "https://urlcut.ai/early-bird",
"destinationUrl": "https://example.com/spring-campaign",
"title": null,
"description": null,
"tags": [],
"status": "active",
"createdAt": "2026-09-15T10:04:11Z",
"expiresAt": null,
"lastClickedAt": null
}
400 and a reason.
Your links, newest first.
Query parameters
| Name | Default | Notes |
|---|---|---|
page | 1 | 1-based. |
pageSize | 25 | Clamped to a maximum of 100. |
search | none | Matches slug, destination and title. |
Response 200 OK
{
"data": [ { "id": 1042, "shortCode": "early-bird", "...": "..." } ],
"page": 1,
"pageSize": 25,
"total": 80,
"hasMore": true
}
Pages are offset-based. If links are created while you are paging, an item can move across a page boundary. For a consistent snapshot, page from the end or fetch quickly.
One link, in the same shape as the create response. Returns 404 if
it does not exist or is not yours.
Changes only the fields you send. Anything you leave out is left alone, so a client that knows about three fields cannot blank the two it has never heard of.
Body
destinationUrl, title, description,
tags, expiresInDays, isActive. All optional.
Deletes the link. Returns 204 No Content, or 404 if it
does not exist or is not yours. The short URL stops resolving
immediately, so anyone who has already shared it will see a dead link.
To stop a link without deleting it, PATCH isActive to false.
Clicks for one link over a window, with country, device, browser and OS splits.
Query parameters
| Name | Notes |
|---|---|
from | ISO date. Omit for all available history. |
to | ISO date, inclusive of that day. |
{
"linkId": 1042,
"from": "2026-09-01T00:00:00Z",
"to": null,
"totalClicks": 318,
"uniqueVisitors": 240,
"clicksByCountry": { "GB": 180, "US": 96 },
"clicksByDevice": { "mobile": 201, "desktop": 117 },
"clicksByBrowser": { "Chrome": 210, "Safari": 88 },
"clicksByOperatingSystem": { "iOS": 140, "Windows": 96 }
}
These are human clicks. Bot and crawler traffic is excluded, which is why the number can be lower than a raw hit count, and why it matches what your dashboard shows for the same range.
from is the window actually served. Your plan
has an analytics retention window, and a request reaching further back is
shortened to it. Read from in the response rather than
assuming you received what you asked for.
Code examples
JavaScript
const res = await fetch("https://urlcut.ai/api/v1/links", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.URLCUT_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
destinationUrl: "https://example.com/spring-campaign",
shortCode: "early-bird",
tags: ["spring", "email"]
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(`${err.code}: ${err.error}`);
}
const link = await res.json();
console.log(link.shortUrl);
Python
import os, requests
res = requests.post(
"https://urlcut.ai/api/v1/links",
headers={"Authorization": f"Bearer {os.environ['URLCUT_API_KEY']}"},
json={
"destinationUrl": "https://example.com/spring-campaign",
"shortCode": "early-bird",
},
timeout=10,
)
res.raise_for_status()
print(res.json()["shortUrl"])
C#
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("URLCUT_API_KEY"));
var res = await http.PostAsJsonAsync("https://urlcut.ai/api/v1/links", new
{
destinationUrl = "https://example.com/spring-campaign",
shortCode = "early-bird"
});
res.EnsureSuccessStatusCode();
var link = await res.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(link.GetProperty("shortUrl").GetString());Errors
Every failure returns the same shape. Branch on code, which is stable;
error is written for a human and may be reworded.
{
"error": "Monthly API quota exceeded. You have used all 100 requests included in your plan this month. The quota resets on 2026-10-01 (UTC).",
"code": "monthly_quota_exceeded",
"statusCode": 429
}
| Status | Code | What it means |
|---|---|---|
| 400 | invalid_request | Something in your request was wrong. The message says what. |
| 401 | - | Missing, malformed, revoked or unknown key. Also returned if your account is suspended. |
| 404 | not_found | No such link, or it is not yours. The two are deliberately identical. |
| 429 | rate_limit_exceeded | Too many requests this minute. Wait and retry. |
| 429 | monthly_quota_exceeded | Your monthly allowance is spent. Waiting a minute will not help. |
| 429 | api_not_included | Your plan has no API access. Waiting will never help; upgrade. |
code
before retrying: backing off for a minute fixes the first and is useless for the
other two.
Rate limits and quota
Two separate limits, and they are counted differently on purpose.
- Rate limit: per key, per minute. Each key gets its own headroom, so one runaway integration cannot starve your others.
- Monthly quota: per account. Shared across every key you hold, so creating a second key does not give you a second allowance.
| Plan | Requests per minute | Requests per month |
|---|---|---|
| Free | 10 | 100 |
| Basic | 120 | 20,000 |
| Pro | 600 | 100,000 |
| Agency | 1,200 | 500,000 |
The Free allowance is sized to prove an integration works, not to run one. The monthly quota resets at midnight UTC on the first of the month.
Headers
Quota headers are on every response, so you can slow down before you run out.
| Header | Meaning |
|---|---|
X-Quota-Limit | Requests included this month. |
X-Quota-Remaining | How many are left. |
X-Quota-Reset | Unix seconds when the month rolls over. |
X-RateLimit-Limit | Requests allowed per minute. Sent on a rate-limit refusal. |
Retry-After | Seconds to wait. Sent on any 429. |
Versioning
The version is in the path: /api/v1. Within v1 we will
add response fields and optional parameters, and we will not remove
or rename anything or change what a field means. So parse defensively: ignore fields
you do not recognise rather than failing on them.
Anything that would break an existing integration arrives as /api/v2, and
v1 keeps working.
Webhooks
The API above is request-driven: you ask, it answers. Webhooks are the other direction. Register a URL under Account, then Webhooks, and we POST to it every time one of your links is clicked, so you do not have to poll for it. Available on Pro and Agency.
What we send
A POST with Content-Type: application/json and these headers:
X-URLcut-Event-Type: link.clicked
X-URLcut-Event-Id: evt_50e75ccf472444c7a87aeb2fc16e8359
X-URLcut-Signature: t=1789722797,v1=488c77da5201f6989a8042364e12c824...
{
"id": "evt_50e75ccf472444c7a87aeb2fc16e8359",
"type": "link.clicked",
"version": "1",
"occurred_at": "2026-09-18T09:13:16Z",
"data": {
"link_id": 98,
"short_code": "spring-sale",
"short_url": "https://urlcut.ai/spring-sale",
"destination_url": "https://example.com/spring?utm_source=instagram",
"title": "Spring sale",
"clicked_at": "2026-09-18T09:13:16Z",
"source": "link",
"country": "US", "region": "Virginia", "city": "Ashburn",
"device_type": "Desktop", "browser": "Chrome", "operating_system": "Mac OS X",
"referrer": "https://news.ycombinator.com/",
"total_clicks": 64,
"tags": ["spring", "sale"]
}
}
occurred_at is when the CLICK happened, not when we sent it, so a
delivery retried hours later still reports the right time. You can choose which
fields you receive when you add the endpoint.
Two things that are deliberately absent
There is no IP address. A webhook is an export to a server we cannot audit, so an address that leaves in a payload sits in your logs permanently and becomes your compliance problem. Geography and device answer the cases we have actually seen.
Bot and crawler clicks fire nothing. Link previews from Slack, WhatsApp and similar fetch every URL they see, and your analytics already excludes them. So the webhooks you receive match the clicks your dashboard counts, and the two can always be reconciled.
Verifying the signature
Every delivery is signed with HMAC-SHA256 using your endpoint's signing secret,
which is shown once when you create the endpoint. The signed material is
"{timestamp}.{raw body}", so verify against the RAW request body before
any JSON parsing, and reject anything whose timestamp is far from now.
import hmac, hashlib, time
def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
During a secret rotation both signatures are sent for 24 hours, so you can deploy the new secret without missing events.
Delivery, retries and failure
- At-least-once, not exactly-once. A retry after a response we
never saw is indistinguishable from a first attempt, so deduplicate on
X-URLcut-Event-Id. - No ordering guarantee. Order by
occurred_atif order matters to you. - Answer 2xx to accept. Anything else is a failure. A 4xx other
than 429 is treated as permanent and not retried, because a 404 will not become
a 200 in six hours. 5xx, 429 and timeouts are retried at roughly 12s, 2m, 30m,
6h, then daily, and your
Retry-Afterheader overrides that. - Reply 410 Gone to tell us to stop permanently.
- We warn, then stop. After 5, 10 and 15 consecutive failures we email you; after 20 we switch the endpoint off and you turn it back on once it is fixed. Your configuration and secret are untouched.
- Respond quickly. We time out after 10 seconds. Queue the work and return 200 rather than processing inline.
The endpoint must be a public https address. Private, internal and
cloud-metadata addresses are refused, at registration and again at delivery.
Webhooks fire on link clicks; events for link expiry and usage thresholds are still
on the roadmap.