Authentication
Every endpoint is authenticated with a personal API token. Create one on the API tokens page in your dashboard, then send it on every request in the Authorization header:
Authorization: Bearer tok_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxA complete request looks like this:
curl https://302.sh/api/links \
-H "Authorization: Bearer tok_..."A few things worth knowing about tokens:
- Shown once. The full token is displayed a single time, at creation. We only store its SHA-256 hash, so we can never show it again — if you lose it, revoke it and create a new one.
- Tokens cannot manage tokens. The
/api/tokensendpoints are session-only on purpose, so a leaked token can touch your links and analytics but can never mint or revoke other tokens. - No CORS or origin friction from a server. Requests from a script, CLI, or backend send no
Originheader, so they are never origin-blocked. From a browser, a cross-originGETworks, but mutating requests (POST,PATCH,DELETE) carrying a foreignOriginare rejected with403 { "error": "forbidden_origin" }— call those from a server, or from a page on 302.sh or your verified custom domain.
A missing or unknown token returns 401 { "error": "unauthorized" }.
Conventions
- IDs vs. slugs. A link has a stable
id(used by the/api/links/{id}routes) and aslug(the part after the domain, used by the analytics routes). They are not interchangeable. - Timestamps are Unix milliseconds. Fields like
created_at,updated_at, andexpires_atare integers in ms since the epoch (ornull). - Booleans on links are 0 / 1. Stored flags such as
disabled,unsafe,cloak, andpassword_protectedcome back as0or1. - Update semantics. On
PATCH, omitting a field leaves it unchanged; sendingnull(or""for a nullable string) clears it. - Errors are a flat shape. Non-2xx responses are a flat object with a stable, machine-readable
errorstring — for example{ "error": "not_found" }. See Errors.
Links
Create a link
POST /api/links — only target is required; a random slug is generated when you omit one.
curl https://302.sh/api/links \
-H "Authorization: Bearer tok_..." \
-H "Content-Type: application/json" \
-d '{"target":"https://example.com"}'Optional body fields: slug, expiresAt (future Unix ms), comment, password, ogTitle, ogDescription, ogImage, geo (a country-to-URL map like {"US":"https://..."}), ios, android, redirectWithQuery, unsafe, and cloak (Pro and up). Returns 201:
{
"link": {
"id": "Ab12Cd34Ef56",
"slug": "xY7k2",
"host": "302.sh",
"target": "https://example.com",
"expires_at": null,
"comment": null,
"disabled": 0,
"password_protected": 0,
"og_title": null,
"og_description": null,
"og_image": null,
"geo": null,
"ios_target": null,
"android_target": null,
"redirect_with_query": 0,
"unsafe": 0,
"cloak": 0,
"created_at": 1750000000000,
"updated_at": 1750000000000
}
}List your links
GET /api/links — supports q (search, up to 200 chars), limit (1–200, default 50), and offset (≥ 0). total is the full filtered count, for pagination.
curl "https://302.sh/api/links?q=promo&limit=50&offset=0" \
-H "Authorization: Bearer tok_..."{
"links": [ { "id": "...", "slug": "...", "target": "...", ... } ],
"total": 128,
"limit": 50,
"offset": 0,
"q": "promo"
}Fetch, update, and delete
These routes take the link id (not the slug).
# Fetch one link
curl https://302.sh/api/links/Ab12Cd34Ef56 \
-H "Authorization: Bearer tok_..."
# -> 200 { "link": { ... } }
# Update — omit a field to keep it, send null to clear it
curl -X PATCH https://302.sh/api/links/Ab12Cd34Ef56 \
-H "Authorization: Bearer tok_..." \
-H "Content-Type: application/json" \
-d '{"target":"https://new.example.com","comment":null}'
# -> 200 { "link": { ... } }
# Delete
curl -X DELETE https://302.sh/api/links/Ab12Cd34Ef56 \
-H "Authorization: Bearer tok_..."
# -> 200 { "ok": true }A link that does not exist — or that you do not own — returns 404 { "error": "not_found" }. The same shape is used for both cases on purpose, so the API never reveals whether a link exists under another account.
Bulk import & export
Export
GET /api/links/bulk-export streams your links in pages of 500. When the response cursor is non-null, pass it back as ?cursor=... to fetch the next page; stop when list_complete is true. Password-protected links export as "passwordProtected": true with no plaintext.
{
"version": 1,
"exportedAt": 1750000000000,
"count": 500,
"cursor": "500",
"list_complete": false,
"links": [
{ "slug": "xY7k2", "target": "https://example.com",
"expiresAt": null, "comment": null }
]
}Import
POST /api/links/bulk-import takes up to 500 links per request. It is idempotent: a slug that already exists is skipped, never overwritten.
curl https://302.sh/api/links/bulk-import \
-H "Authorization: Bearer tok_..." \
-H "Content-Type: application/json" \
-d '{"links":[
{"target":"https://a.example.com","slug":"a"},
{"target":"https://b.example.com"}
]}'{
"imported": 1,
"skipped": 1,
"conflicts": 1,
"linkLimit": 25,
"used": 12,
"tier": "free",
"results": [
{ "index": 0, "ok": false, "error": "slug_taken" },
{ "index": 1, "ok": true, "slug": "qZ8m1", "id": "..." }
]
}Analytics
Analytics routes are keyed by slug and cover a rolling 90-day window (older click data is removed automatically). Bots are excluded by default; add ?includeBots=1 to include them.
Summary
GET /api/analytics/{slug} returns totals plus per-dimension breakdowns and a daily time series.
curl https://302.sh/api/analytics/xY7k2 \
-H "Authorization: Bearer tok_..."{
"slug": "xY7k2",
"range": { "fromDays": 90 },
"totalClicks": 1240,
"uniqueVisitors": 38,
"botClicks": 91,
"timeseries": [ { "date": "2026-06-01", "clicks": 42 } ],
"byCountry": { "US": 800, "GB": 120 },
"byDevice": { "mobile": 900, "desktop": 340 },
"byReferer": { "t.co": 210, "(direct)": 600 },
"hourlyHeatmap": [ { "day": 1, "hour": 9, "clicks": 7 } ]
}Other breakdown maps include byRegion, byCity, byOs, byBrowser, byLanguage, and byTimezone.
Recent events
GET /api/analytics/{slug}/recent returns up to 50 of the most recent click events from the last 24 hours.
{
"slug": "xY7k2",
"events": [
{
"timestamp": "2026-06-20T14:03:22Z",
"country": "US",
"region": "California",
"city": "San Francisco",
"device": "mobile",
"browser": "Safari",
"os": "iOS",
"referer": "t.co",
"isBot": false
}
]
}GET /api/analytics/events returns the same event shape across all of your links (each event also carries its slug), or for one link with ?slug=.... It accepts limit (1–200, default 50).
Export CSV
GET /api/analytics/{slug}/export returns text/csv rather than JSON. Use ?type=timeseries (default — a date,clicks file) or ?type=events (one row per click).
curl "https://302.sh/api/analytics/xY7k2/export?type=events" \
-H "Authorization: Bearer tok_..." \
-o xY7k2-events.csvErrors
Errors use standard HTTP status codes with a flat JSON body — a single error field, for example { "error": "slug_taken" }. The error string is stable and safe to branch on.
| Status | Example code | Meaning |
|---|---|---|
| 400 | target_url_invalid, slug_invalid, expiresAt_in_past | The request body failed validation. |
| 401 | unauthorized | Missing or unknown API token. |
| 402 | link_limit_reached, cloak_requires_pro | A plan quota or gate blocked the request. |
| 404 | not_found | The link does not exist, or is not yours. |
| 409 | slug_taken | The requested slug is already in use. |
Quotas & limits
Your plan sets two ceilings, and they behave differently — both are defined on the pricing page:
- Link count is a hard cap. Once you reach your plan’s limit,
POST /api/links(and the equivalent rows in a bulk import) returns402 { "error": "link_limit_reached" }until you delete a link or upgrade. - Tracked clicks per month is a soft cap. Redirects always keep working — when you exceed the monthly quota we simply stop recording new analytics events for the rest of the period. A link never breaks for billing reasons.
Some routing features are gated by tier (for example, link cloaking requires Pro), and those return a 402 with a specific code such as cloak_requires_pro.
This page covers the stable, supported surface. The full contract — including every field and error code — lives in the machine-readable OpenAPI 3.1 spec.