Developers

API reference.

Create and manage short links, and pull click analytics, straight from your own scripts, CLIs, and apps. The base URL is https://302.sh and responses are JSON, except the analytics CSV export, which returns text/csv.

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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A complete request looks like this:

curl https://302.sh/api/links \
  -H "Authorization: Bearer tok_..."

A few things worth knowing about tokens:

A missing or unknown token returns 401 { "error": "unauthorized" }.

Conventions

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.csv

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.

StatusExample codeMeaning
400target_url_invalid, slug_invalid, expiresAt_in_pastThe request body failed validation.
401unauthorizedMissing or unknown API token.
402link_limit_reached, cloak_requires_proA plan quota or gate blocked the request.
404not_foundThe link does not exist, or is not yours.
409slug_takenThe requested slug is already in use.

Quotas & limits

Your plan sets two ceilings, and they behave differently — both are defined on the pricing page:

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.