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.