uwu.land API
A small JSON API for shortening links. The dashboard runs on exactly this API; there are no private endpoints.
Base URL
https://uwu.land/api/v1
Shorten a link in one call, no account required:
curl -X POST https://uwu.land/api/v1/links \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/some/long/path"}'Authentication
Every authenticated request uses Authorization: Bearer <token>. The token is either an API key (it starts with uwu_) or a Clerk session token that the dashboard sends automatically. Cookies are never read.
API keys act as their owning account: links created with any of the account's keys or the dashboard belong to the same account and appear in the same list.
Authorization: Bearer uwu_your_api_key
/links and /me accept either token type, and POST /links also works with no token at all. The /keys endpoints accept only a Clerk session; an API key gets 403 there, so a leaked key can never mint more keys.
Endpoints
| Endpoint | Auth | Notes |
|---|---|---|
| POST /links | Optional | Create a short link. Anonymous callers get random slugs only. |
| GET /links | Required | List your links, newest first, cursor paginated. |
| GET /links/:slug | Required (owner) | Fetch one of your links. |
| GET /links/:slug/stats | None | Public total click count. |
| DELETE /links/:slug | Required (owner) | Delete one of your links. |
| GET /me | Required | Your tier and limits. |
| POST /keys | Clerk session only | Create an API key (dashboard action). |
| GET /keys | Clerk session only | List your keys. |
| DELETE /keys/:id | Clerk session only | Revoke a key. |
The link object
Every endpoint that returns a link, whether a list item or a single GET, uses this shape:
{
"slug": "my-link",
"short_url": "https://uwu.land/my-link",
"url": "https://example.com/some/long/path",
"clicks": 42,
"created_at": "2026-07-10T12:00:00.000Z",
"external_ref": "discord:214836288048594944"
}short_url is the canonical link. clicks is an integer. created_at is a UTC ISO 8601 timestamp. external_ref is omitted entirely when absent, never null.
Create a link
Anonymous callers may only send url; sending slug or external_ref returns 403 forbidden. With a key or session you can also pick a custom slug and attach an external_ref.
The url must be http or https, at most 2048 characters, carry no embedded credentials (user:pass@), and not point at uwu.land or any of its subdomains, so you cannot nest or loop short links. Rejected URLs return 400 invalid_body; banned destinations return 400 url_banned.
A custom slug is 3 to 16 characters of letters, numbers, underscores, and hyphens (ASCII only). Slugs are case-sensitive and stored exactly as sent, so /Tommy and /tommy are different links. A few names such as api are reserved (matched case-insensitively).
curl -X POST https://uwu.land/api/v1/links \
-H "Authorization: Bearer uwu_your_api_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/some/long/path", "slug": "my-link"}'The same call from JavaScript, reading the error envelope on failure:
const res = await fetch("https://uwu.land/api/v1/links", {
method: "POST",
headers: {
Authorization: "Bearer uwu_your_api_key",
"Content-Type": "application/json"
},
body: JSON.stringify({ url: "https://example.com/some/long/path", slug: "my-link" })
});
const data = await res.json();
if (!res.ok) {
throw new Error(`${data.code}: ${data.message}`);
}
console.log(data.short_url);A 201 returns the new link object.
List your links
Results come newest first, 25 per page, and are cursor paginated. Omit cursor for the first page. When more pages exist the response includes a cursor; pass it back as ?cursor=. The last page omits the cursor field. A malformed cursor returns 400 invalid_body.
{
"links": [
{
"slug": "my-link",
"short_url": "https://uwu.land/my-link",
"url": "https://example.com/some/long/path",
"clicks": 42,
"external_ref": "discord:214836288048594944",
"created_at": "2026-07-10T12:00:00.000Z"
}
],
"cursor": "eyJjcmVhdGVkX2F0IjoiLi4uIn0"
}Filter with ?external_ref=. The filter is not baked into the cursor, so repeat it on every page request. GET /links/:slug returns a single link object. DELETE /links/:slug returns 204 with no body.
Redirects & clicks
Short links redirect with HTTP 302. Every successful redirect counts one click, including bots and link-preview crawlers, so a link often has a few clicks before anyone opens it. Counting happens in the background, so totals are eventually consistent and may lag a few seconds. Query params on the short URL are not merged into the destination; the stored URL is used unchanged. Unknown slugs redirect (302) to the 404 page.
Public stats
Total clicks for any slug are public and need no authentication.
GET /api/v1/links/my-link/stats
{ "slug": "my-link", "clicks": 42 }external_ref: tag links per user
external_ref is an opaque metadata tag (up to 64 characters) you attach to a link, not an isolation boundary. It does not scope visibility. The account sees and controls every link created through its dashboard session or any of its API keys, regardless of external_ref. A Discord bot, for example, tags links with discord:<userId>, then lists one user's links with GET /links?external_ref=discord:214836288048594944.
Because the ref is not enforced, before acting for an end user, especially on deletes, fetch the link and confirm its external_ref matches that user. Possession of a slug proves nothing.
Errors
Success statuses are 201 for POST /links, 200 for GETs, and 204 for DELETEs. All errors share one envelope with a stable code:
{ "status": 409, "code": "slug_taken", "message": "Slug is already taken." }| Status | Code | When |
|---|---|---|
| 400 | invalid_body | Malformed JSON, URL, slug, or cursor. |
| 400 | slug_reserved | The requested slug is reserved (for example, api). |
| 400 | url_banned | The destination is blocked by moderation. |
| 401 | unauthorized | Auth is missing, invalid, or revoked. |
| 403 | forbidden | The request is understood but not permitted: an anonymous caller sent a restricted field, you are not the link owner, or an API key called a /keys endpoint. |
| 404 | not_found | The link or key does not exist. |
| 409 | slug_taken | The requested slug already exists. |
| 409 | key_limit | Your account reached its API key limit. |
| 429 | rate_limited | You exceeded your daily link-creation quota. |
Rate limits
Link-creation quotas are per day. Anonymous callers are limited per IP; authenticated callers are limited per account, shared across every API key and the dashboard.
| Tier | Links per day | API keys |
|---|---|---|
| Anonymous | 15 | None |
| Free | 120 | 1 |
| First-Class | 2000 | 10 |
Exceeding the quota returns 429 with code rate_limited. A 429 includes retry_after (integer seconds) in the error body and a matching Retry-After header.
First-Class (the pro tier) costs $4/month or $36/year; Free and anonymous use stay free forever. Subscriptions are purchased and managed from the account dashboard through Stripe Checkout and the Stripe Billing Portal.
Your account
GET /api/v1/me
{
"user_id": "user_2abc...",
"tier": "free",
"limits": { "createPerDay": 120, "apiKeys": 1, "displayName": "Free" },
"usage": { "createdToday": 14, "apiKeys": 1, "resetAt": "2026-07-11T09:30:00.000Z" }
}limits are the static values for your tier; usage shows what you have consumed: createdToday counts link creations in the current daily window and resetAt (UTC ISO 8601, null before your first create of the window) is when it resets.
Key management
Dashboard-only endpoints
The /keys endpoints authenticate with a Clerk session, which the dashboard sends for you. An API key gets 403 here, so you manage keys in the dashboard rather than by hand.
Secrets are stored only as one-way hashes and shown exactly once at creation. display_prefix contains the first 12 characters and is safe to use when identifying a key.
POST /api/v1/keys
{ "name": "my-discord-bot" }
201:
{
"id": "9f4c2f8a-...",
"name": "my-discord-bot",
"secret": "uwu_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6",
"display_prefix": "uwu_a1B2c3D4"
}GET /keys lists your keys without secrets. DELETE /keys/:id revokes one and returns 204; a revoked key is rejected immediately with 401 unauthorized.