Skip to content

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

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.

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

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.

Terminal window
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.

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.

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.

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.

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.