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
  1. 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.
  2. Send it as a bearer token on every request.
  3. Create your first link with the call below.
Your first request
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"}'
Free accounts get a small allowance so you can confirm an integration works before paying for it: 100 requests a month at 10 a minute. See rate limits and quota for the paid plans.

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.

Header Authorization
Authorization: Bearer urlc_46bf8fb5a3aa_SW3m6vxSvufhwWMwGfBOp3LYYzYyeUoKE4BJHpThPQ

What 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.
We store a hash of your key, never the key itself. That is why we cannot show it to you again and cannot recover it for you. If it is lost, revoke it and create another.

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.

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
}
StatusCodeWhat it means
400invalid_requestSomething in your request was wrong. The message says what.
401-Missing, malformed, revoked or unknown key. Also returned if your account is suspended.
404not_foundNo such link, or it is not yours. The two are deliberately identical.
429rate_limit_exceededToo many requests this minute. Wait and retry.
429monthly_quota_exceededYour monthly allowance is spent. Waiting a minute will not help.
429api_not_includedYour plan has no API access. Waiting will never help; upgrade.
Three different 429s, three different remedies. Check 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.
PlanRequests per minuteRequests per month
Free10100
Basic12020,000
Pro600100,000
Agency1,200500,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.

HeaderMeaning
X-Quota-LimitRequests included this month.
X-Quota-RemainingHow many are left.
X-Quota-ResetUnix seconds when the month rolls over.
X-RateLimit-LimitRequests allowed per minute. Sent on a rate-limit refusal.
Retry-AfterSeconds 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_at if 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-After header 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.

Frequently asked questions

Yes. Create a key under Account, then API keys, and call https://urlcut.ai/api/v1. Every plan including Free has an allowance, and the endpoints on this page are live.

Free accounts get 100 requests a month at 10 a minute, which is enough to confirm an integration works rather than to run one. Basic allows 20,000 a month at 120 a minute, Pro 100,000 at 600 a minute, and Agency 500,000 at 1,200 a minute.

No. We store a hash of the key and never the key itself, so we cannot show it to you again or recover it for you. Revoke the lost key and create a new one. This is also why a leaked key cannot be read out of our database.

No. The monthly quota belongs to your account and is shared across every key you hold. The per-minute rate limit is the one that is per key, so separate keys stop one busy integration from starving another.

ASCII letters (a-z and A-Z), numbers (0-9) and hyphens (-), up to 50 characters. A small set of words is reserved for our own routes and is refused with a message naming the code you tried. If you send a slug that is already taken or not allowed, the request fails with 400 and the reason.

No. Slugs cannot be changed after creation, which is what keeps an already shared link working forever. You can update the destination URL, title, description, tags, expiry and active status. To change a slug, create a new link and delete the old one.

Analytics count human clicks and exclude bot and crawler traffic, which is why they match what your dashboard shows for the same range. Your plan also has an analytics retention window, and a request reaching further back than that window is shortened to it, so read the from field in the response rather than assuming you received the range you asked for.

No. Key management is deliberately unreachable with a key, so a leaked key cannot mint itself a replacement that would survive you revoking the original. Creating a key requires signing in and confirming your password.

Yes, for link clicks, on Pro and Agency. Add an endpoint under Account, then Webhooks, and every click POSTs a signed JSON payload to your server, so you do not have to poll for it. Each delivery is signed with HMAC-SHA256, retried on failure, and recorded in a delivery log you can read. Webhooks for link expiration and usage thresholds are still on the roadmap and are not available on any plan.

Related reading