Authentication
Every endpoint is authenticated with a personal API token, available on Creator and higher plans. 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. - Publishing needs a scope. A token can read everything you own out of the box, but writing a bio page — the one thing in this API that publishes a public page under your name — needs the
bio:writescope, ticked when you create the token. Existing tokens do not have it, and a token without it gets403 { "error": "token_scope_required" }. - Daily limits follow your plan. Creator includes 10,000 requests per token per UTC day, Pro 100,000, and Business 500,000. A limit response is
429and includesRetry-After. - 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, host (an allowed hostname, including an active verified custom domain), expiresAt (future Unix ms), comment, password, ogTitle, ogDescription, ogImage, geo (a country-to-URL map like {"US":"https://..."}), ios, android, redirectWithQuery, unsafe, and cloak (Creator and up). Returns 201:
{
"link": {
"id": "Ab12Cd34Ef56",
"slug": "xY7k2",
"host": "3go.to",
"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 and requires Pro or higher. 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": 738,
"uniqueVisitorsPartial": false,
"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.csvBio pages
A bio page is a hosted link-in-bio page at https://302.gallery/{handle}. Unlike every other resource in this API, writing one publishes a page we serve from our own certificate — so the write routes need the bio:write scope on your token, while reading needs nothing beyond the token itself.
| Endpoint | Scope |
|---|---|
GET /api/bio | — |
GET /api/analytics/bio/{handle}, …/export | — |
GET /api/bio/check | bio:write |
POST /api/bio, PATCH /api/bio/{id}, DELETE /api/bio/{id} | bio:write |
GET /api/bio lists your pages along with your tier and its page limit. POST /api/bio takes a handle plus any of the content fields; everything except the handle is optional, and a page is created unpublished unless you pass published: true.
curl https://302.sh/api/bio \
-H "Authorization: Bearer tok_..." \
-H "Content-Type: application/json" \
-d '{
"handle": "ada",
"displayName": "Ada Lovelace",
"bio": "Notes on the Analytical Engine.",
"theme": "porcelain",
"blocks": [
{ "label": "Newsletter", "url": "https://example.com/news" },
{ "kind": "heading", "label": "Elsewhere" },
{ "label": "Talks", "url": "https://example.com/talks" }
],
"socials": [
{ "platform": "github", "url": "https://github.com/example" }
],
"published": true
}'PATCH /api/bio/{id} takes the same fields; omitted ones keep their current value, and sending blocks replaces the whole array. The avatar is always a copy of your account’s sign-in picture and cannot be set through the API.
Analytics for a page are keyed by handle, not slug, and report page views and outbound block presses separately: GET /api/analytics/bio/{handle} returns views, clicks, and byBlock. The CSV export accepts ?type=timeseries (default), ?type=blocks, or ?type=events.
Errors
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, password_requires_creator | A plan quota or gate blocked the request. |
| 403 | token_scope_required | The token is valid but lacks the scope the route needs; the body names it. Create a new token with that box ticked. |
| 404 | not_found | The link does not exist, or is not yours. |
| 409 | slug_taken | The requested slug is already in use. |
| 429 | rate_limited | The token has reached its plan’s daily API limit. |
Quotas & limits
Your plan sets three ceilings, and they behave differently — all 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. - Analytics events per month is a soft cap. Reaching this allowance stops new analytics events for the rest of the period; it does not itself stop redirects. Deletion, expiration, owner-set restrictions and safety enforcement can still prevent a redirect.
- Public API requests have a daily cap. The server counts requests per token per UTC day and returns
429after the active plan’s limit. Requests admitted by the authentication and quota checks can consume this allowance even if they fail later. Other safety limits can also return 429; inspect the error code and Retry-After header.
Routing features are gated by tier: password protection, cloaking, and smart routing start at Creator; bulk import and A/B split routing start at Pro; click caps start at Business. These return a 402 with a stable feature-specific code.
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.



