API reference
Base URL https://301.so. All endpoints take and return JSON, require a Bearer
token (Authentication) and an explicit User-Agent.
| Method | Path | What it does |
|---|---|---|
GET |
/api/links |
list the team’s links |
GET |
/api/links/:id |
one link |
POST |
/api/links |
create a link |
PATCH |
/api/links/:id |
change a link |
DELETE |
/api/links/:id |
archive a link (soft) |
GET |
/api/stats |
clicks per link and per day |
The link object
Section titled “The link object”Every link endpoint answers with this shape:
{ "id": "lnk_aKUzWaeoKA64FmK6", "shortUrl": "https://301.so/docs-check", "hostname": "301.so", "slug": "docs-check", "url": "https://www.youtube.com/@VincentLeSerpent", "title": "Doc check", "note": null, "status": 302, "deeplink": null, "utm": { "utm_source": "docs" }, "expiresAt": null, "archivedAt": null, "createdAt": 1787997074006, "updatedAt": 1787997074006}Timestamps are epoch milliseconds. deeplink and utm are stored as JSON and
returned parsed; if the stored JSON is unreadable, the field comes back null
rather than failing the request.
GET /api/links
Section titled “GET /api/links”Returns { "links": [...] }, newest first, capped at 200. There is no
pagination — the day that cap is reached is the day this endpoint needs a
cursor, and it will get one then.
| Query | Default | Effect |
|---|---|---|
archived |
false |
archived=true also returns archived links |
GET /api/links/:id
Section titled “GET /api/links/:id”The link, or 404 {"error":"not_found"} — which is also what you get for a link
that exists but belongs to another team. Existence is not something the API
confirms across a team boundary.
POST /api/links
Section titled “POST /api/links”curl -s -X POST https://301.so/api/links \ -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \ -A "my-client/1.0" \ -d '{"url":"https://www.youtube.com/@VincentLeSerpent","slug":"docs-check","utm":{"utm_source":"docs"}}'Answers 201 with the link object.
| Field | Required | Notes |
|---|---|---|
url |
yes | http: or https: only |
slug |
no | lowercased; [a-z0-9._-]+, ≤ 64 chars, not reserved. Omitted → random 7 chars |
hostname |
no | with one domain on the team, it is inferred |
title, note |
no | free text, for your own listings |
status |
no | one of 301, 302, 307, 308. Default 302 |
deeplink |
no | override object, see Deep links |
utm |
no | {"key":"value"} merged into the destination |
expiresAt |
no | epoch ms |
Reserved slugs are refused with invalid_slug. They are the paths the
worker already serves or is keeping: api, _debug, admin, dashboard,
login, logout, robots.txt, favicon.ico, sitemap.xml,
sitemap-index.xml, sitemap-0.xml, health, static, assets, _astro,
docs, _next, well-known, and anything starting with .well-known.
PATCH /api/links/:id
Section titled “PATCH /api/links/:id”Send only what changes. Every field of POST is accepted except hostname
(a link does not move between domains), and an empty body is an error rather
than a no-op:
{ "error": "nothing_to_update" }Passing null clears title, note, expiresAt, deeplink and utm.
Renaming a slug purges the old cache key as well as writing the new one — without that, the old address would keep serving the old destination until its TTL expired.
DELETE /api/links/:id
Section titled “DELETE /api/links/:id”Archiving, not deletion:
{ "id": "lnk_aKUzWaeoKA64FmK6", "archived": true }The row stays, the cache entry is dropped, the short URL starts returning 404 —
and the slug stays taken (409 slug_taken if you try to recreate it). That
is the point: an address printed in someone’s bio must never wake up one day
pointing at something unrelated.
GET /api/stats
Section titled “GET /api/stats”| Query | Default | Range |
|---|---|---|
days |
30 |
clamped to 1–365 |
{ "depuis": "2026-08-22", "jours": 7, "total": 12, "parLien": [ { "shortUrl": "https://301.so/yt-ig", "slug": "yt-ig", "titre": "YouTube from Instagram", "clics": 8, "bots": 5 } ], "parJour": [{ "jour": "2026-08-27", "clics": 3, "bots": 1 }]}Bots are counted, separately, and never folded into clics. Archived links are
excluded. Served from D1, not Analytics Engine — Stats explains
the split.
Errors
Section titled “Errors”| HTTP | error |
When |
|---|---|---|
| 400 | invalid_json |
body isn’t JSON |
| 400 | invalid_url |
missing, or not http(s) |
| 400 | invalid_slug |
bad characters, too long, or reserved |
| 400 | invalid_status |
not 301/302/307/308 |
| 400 | unknown_domain |
hostname unknown, or ambiguous with several domains |
| 400 | nothing_to_update |
PATCH with no known field |
| 401 | unauthorized |
missing, malformed, unknown or revoked token |
| 404 | not_found |
no such link for this team |
| 409 | slug_taken |
slug already used on this domain (includes the slug) |
403 with error code: 1010 in an HTML body is not this API — that is
Cloudflare rejecting your user-agent before the worker runs.