{
  "openapi": "3.1.0",
  "info": {
    "title": "302.sh API",
    "version": "2026-05-17",
    "description": "Public REST API for 302.sh, a lightweight short-link service. Authenticate with a personal API token created at https://302.sh/dashboard/account. See the **MCP integration** section of the README for setup with Claude Desktop and other MCP-aware clients.\n\n**Stability:** endpoints below are considered stable. Anything not documented here (e.g. /api/auth/*, /api/billing/*, /api/me) is internal to the dashboard and may change without notice.\n\n**Rate limits:** per-IP throttling on link creation; soft per-month analytics quota that affects which events are written to the analytics store (redirects always continue working). See the pricing page for current tier numbers.\n\n**Source-of-truth note:** this spec is hand-maintained in lockstep with `worker/handlers/*.ts`. Behaviour drift between code and spec is a bug — please file an issue at https://github.com/lessmorepro/302.sh/issues."
  },
  "servers": [
    { "url": "https://302.sh", "description": "Production" }
  ],
  "security": [{ "bearerAuth": [] }],
  "tags": [
    { "name": "links", "description": "Short link CRUD" },
    { "name": "bulk", "description": "JSON import/export of all your links" },
    { "name": "analytics", "description": "90-day click analytics" }
  ],
  "paths": {
    "/api/links": {
      "get": {
        "tags": ["links"],
        "summary": "List your links",
        "parameters": [
          { "name": "q", "in": "query", "description": "Optional search term — matches against slug, target, comment, og_title with `LIKE`. Empty/whitespace-only falls back to the unfiltered list.", "schema": { "type": "string", "maxLength": 200 } },
          { "name": "limit", "in": "query", "description": "Max rows per response. Default 50, max 200.", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["links", "limit", "offset"],
                  "properties": {
                    "links": { "type": "array", "items": { "$ref": "#/components/schemas/Link" } },
                    "limit": { "type": "integer" },
                    "offset": { "type": "integer" },
                    "q": { "type": "string", "description": "Echoed search term (only when q was provided)" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["links"],
        "summary": "Create a short link",
        "description": "Slug is auto-generated if not provided. Hard plan-tier limit on link count is enforced at create time.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreateLinkBody" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "link": { "$ref": "#/components/schemas/Link" } } } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "409": { "$ref": "#/components/responses/Conflict" }
        }
      }
    },
    "/api/links/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The link's id (NOT the slug — see Link.id). Returned by POST /api/links and GET /api/links." }
      ],
      "get": {
        "tags": ["links"],
        "summary": "Fetch a single link",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "link": { "$ref": "#/components/schemas/Link" } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      },
      "patch": {
        "tags": ["links"],
        "summary": "Update a link",
        "description": "Only included fields are updated. Send `null` to clear a nullable field; omit to keep current value.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateLinkBody" } } }
        },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "link": { "$ref": "#/components/schemas/Link" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      },
      "delete": {
        "tags": ["links"],
        "summary": "Delete a link",
        "responses": {
          "200": {
            "description": "Deleted",
            "content": { "application/json": { "schema": { "type": "object", "required": ["ok"], "properties": { "ok": { "type": "boolean", "enum": [true] } } } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/links/bulk-export": {
      "get": {
        "tags": ["bulk"],
        "summary": "Cursor-paginated export of your links as JSON",
        "description": "Returns one page at a time. When `cursor` is non-null, pass it back as `?cursor=...` to fetch the next page. `list_complete: true` signals there are no more pages.\n\nThe per-row shape is the same one accepted by `/api/links/bulk-import`, so the export → import round trip is lossless EXCEPT for password-protected links: we never have plaintext, so the export marks them with `passwordProtected: true` and on re-import the caller must re-supply the password.",
        "parameters": [
          { "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Opaque offset from a previous response. Omit for the first page." }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["version", "exportedAt", "count", "list_complete", "links"],
                  "properties": {
                    "version": { "type": "integer", "enum": [1], "description": "Export format version." },
                    "exportedAt": { "type": "integer", "description": "Unix milliseconds at which this page was produced." },
                    "count": { "type": "integer", "description": "Rows on this page (not the user's total)." },
                    "cursor": { "type": ["string", "null"], "description": "Next-page cursor, or null when complete." },
                    "list_complete": { "type": "boolean" },
                    "links": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": ["slug", "target"],
                        "properties": {
                          "slug": { "type": "string" },
                          "target": { "type": "string", "format": "uri" },
                          "expiresAt": { "type": ["integer", "null"] },
                          "comment": { "type": ["string", "null"] },
                          "passwordProtected": { "type": "boolean", "description": "Present and true when the source link had a password. Plaintext is never exported." }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/api/links/bulk-import": {
      "post": {
        "tags": ["bulk"],
        "summary": "Idempotently import an array of links",
        "description": "Slug collisions are skipped (NOT overwritten); the response surfaces a summary of created vs skipped counts so callers can diff against the request.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["links"],
                "properties": {
                  "links": { "type": "array", "items": { "$ref": "#/components/schemas/CreateLinkBody" } }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Summary",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["imported", "skipped", "conflicts", "results"],
                  "properties": {
                    "imported": { "type": "integer", "description": "Links inserted on this call." },
                    "skipped": { "type": "integer", "description": "Slugs already taken (treated as success — import is idempotent)." },
                    "conflicts": { "type": "integer", "description": "Subset of `skipped` whose existing target differs from the imported one." },
                    "linkLimit": { "type": "integer", "description": "Hard link cap for the caller's tier; mirror of shared/plans.ts." },
                    "used": { "type": "integer", "description": "Caller's current link count after this import." },
                    "tier": { "type": "string", "enum": ["free", "pro", "business"] },
                    "results": { "type": "array", "items": { "type": "object", "properties": { "slug": { "type": "string" }, "status": { "type": "string", "enum": ["created", "skipped", "error"] }, "error": { "type": "string" } } } }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" }
        }
      }
    },
    "/api/analytics/{slug}": {
      "parameters": [
        { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
        { "name": "includeBots", "in": "query", "schema": { "type": "string", "enum": ["1"] }, "description": "Pass `?includeBots=1` to include bot-flagged events. Default behaviour excludes bots from totals and breakdowns." }
      ],
      "get": {
        "tags": ["analytics"],
        "summary": "90-day analytics summary for one slug",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AnalyticsSummary" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/analytics/{slug}/recent": {
      "parameters": [
        { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
        { "name": "includeBots", "in": "query", "schema": { "type": "string", "enum": ["1"] } }
      ],
      "get": {
        "tags": ["analytics"],
        "summary": "Last-24h event tail (up to 50 rows)",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["slug", "events"],
                  "properties": {
                    "slug": { "type": "string" },
                    "events": { "type": "array", "items": { "$ref": "#/components/schemas/ClickEvent" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/analytics/{slug}/export": {
      "parameters": [
        { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
        { "name": "type", "in": "query", "schema": { "type": "string", "enum": ["timeseries", "events"], "default": "timeseries" }, "description": "`timeseries` (default) returns one row per day as `date,clicks` CSV. `events` returns up to 10k raw per-click rows across the 90-day retention window." },
        { "name": "includeBots", "in": "query", "schema": { "type": "string", "enum": ["1"] } }
      ],
      "get": {
        "tags": ["analytics"],
        "summary": "Download analytics as CSV",
        "responses": {
          "200": {
            "description": "CSV file",
            "content": { "text/csv": { "schema": { "type": "string" } } },
            "headers": {
              "Content-Disposition": { "schema": { "type": "string", "example": "attachment; filename=\"slug-events.csv\"" } }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/analytics/events": {
      "get": {
        "tags": ["analytics"],
        "summary": "Recent event tail across one or all of your slugs",
        "parameters": [
          { "name": "slug", "in": "query", "schema": { "type": "string" }, "description": "Restrict to one slug (verified for ownership in D1). Omit to get the cross-account tail for the caller." },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
          { "name": "includeBots", "in": "query", "schema": { "type": "string", "enum": ["1"] } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["events"],
                  "properties": {
                    "slug": { "type": ["string", "null"] },
                    "events": { "type": "array", "items": { "$ref": "#/components/schemas/ClickEvent" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "tok_<base62, 32 chars>",
        "description": "Create a token at https://302.sh/dashboard/account → API tokens. The token is shown ONCE at creation time; we only store its SHA-256. Tokens cannot mint or revoke other tokens (cookie session is required for that)."
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid bearer token",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "unauthorized" } } }
      },
      "NotFound": {
        "description": "Resource not found OR not owned by the caller (we return the same shape to avoid an existence oracle).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "not_found" } } }
      },
      "BadRequest": {
        "description": "Validation failed. `error` is a stable machine code (e.g. `target_url_invalid`, `slug_invalid`, `expiresAt_in_past`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Conflict": {
        "description": "Slug collision.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "slug_taken" } } }
      },
      "PaymentRequired": {
        "description": "Plan tier doesn't allow this. `error` is e.g. `link_limit_reached` or `cloak_requires_pro`.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": { "type": "string", "description": "Stable machine code. UI-facing copy comes from the client dictionary." }
        }
      },
      "Link": {
        "type": "object",
        "required": ["id", "slug", "target", "created_at", "updated_at"],
        "properties": {
          "id": { "type": "string", "description": "Opaque 12-char identifier, used as the path parameter on /api/links/:id." },
          "slug": { "type": "string", "description": "The short link path: `https://302.sh/<slug>`." },
          "target": { "type": "string", "format": "uri" },
          "expires_at": { "type": ["integer", "null"], "description": "Unix milliseconds." },
          "comment": { "type": ["string", "null"], "description": "Private note (never on the redirect path)." },
          "disabled": { "type": "integer", "enum": [0, 1] },
          "password_hash": { "type": ["string", "null"], "description": "Presence indicates the link is password-gated. Plaintext is never returned." },
          "og_title": { "type": ["string", "null"] },
          "og_description": { "type": ["string", "null"] },
          "og_image": { "type": ["string", "null"], "format": "uri" },
          "geo": { "type": ["object", "null"], "additionalProperties": { "type": "string" }, "description": "Country-code → URL map (uppercase ISO 3166-1 alpha-2)." },
          "ios_target": { "type": ["string", "null"], "format": "uri" },
          "android_target": { "type": ["string", "null"], "format": "uri" },
          "redirect_with_query": { "type": ["integer", "null"], "enum": [0, 1, null], "description": "When 1, visitor query string merges onto the target (visitor wins on collision)." },
          "unsafe": { "type": ["integer", "null"], "enum": [0, 1, null], "description": "When 1, route through the warning interstitial before the redirect." },
          "cloak": { "type": ["integer", "null"], "enum": [0, 1, null], "description": "When 1, serve an iframe wrapper instead of a 302. Pro+ only." },
          "created_at": { "type": "integer", "description": "Unix milliseconds." },
          "updated_at": { "type": "integer", "description": "Unix milliseconds." }
        }
      },
      "CreateLinkBody": {
        "type": "object",
        "required": ["target"],
        "properties": {
          "target": { "type": "string", "format": "uri" },
          "slug": { "type": "string", "description": "Optional custom slug. Auto-generated if omitted." },
          "expiresAt": { "type": "integer", "description": "Unix milliseconds (future)." },
          "comment": { "type": "string", "maxLength": 2048 },
          "password": { "type": "string", "description": "Hashed server-side before storage; never echoed." },
          "ogTitle": { "type": "string", "maxLength": 200 },
          "ogDescription": { "type": "string", "maxLength": 500 },
          "ogImage": { "type": "string", "format": "uri" },
          "geo": { "type": "object", "additionalProperties": { "type": "string", "format": "uri" } },
          "ios": { "type": "string", "format": "uri" },
          "android": { "type": "string", "format": "uri" },
          "redirectWithQuery": { "type": "boolean" },
          "unsafe": { "type": "boolean" },
          "cloak": { "type": "boolean", "description": "Pro+ only — server returns 402 cloak_requires_pro for free tier." }
        }
      },
      "UpdateLinkBody": {
        "type": "object",
        "description": "All fields optional. `null` clears a nullable field; omitting keeps current value.",
        "properties": {
          "target": { "type": "string", "format": "uri" },
          "expiresAt": { "type": ["integer", "null"] },
          "comment": { "type": ["string", "null"], "maxLength": 2048 },
          "disabled": { "type": "boolean" },
          "password": { "type": ["string", "null"], "description": "Empty string or null clears the password." },
          "ogTitle": { "type": ["string", "null"], "maxLength": 200 },
          "ogDescription": { "type": ["string", "null"], "maxLength": 500 },
          "ogImage": { "type": ["string", "null"], "format": "uri" },
          "geo": { "type": ["object", "null"], "additionalProperties": { "type": "string", "format": "uri" } },
          "ios": { "type": ["string", "null"], "format": "uri" },
          "android": { "type": ["string", "null"], "format": "uri" },
          "redirectWithQuery": { "type": ["boolean", "null"] },
          "unsafe": { "type": ["boolean", "null"] },
          "cloak": { "type": ["boolean", "null"], "description": "Pro+ only on enable; free users can always disable (turn off)." }
        }
      },
      "AnalyticsSummary": {
        "type": "object",
        "required": ["slug", "range", "totalClicks", "uniqueVisitors", "botClicks", "timeseries"],
        "properties": {
          "slug": { "type": "string" },
          "range": { "type": "object", "properties": { "fromDays": { "type": "integer", "example": 90 } } },
          "totalClicks": { "type": "integer" },
          "uniqueVisitors": { "type": "integer", "description": "Distinct visitor IPs are not retained; this is currently `distinct countries`." },
          "botClicks": { "type": "integer" },
          "timeseries": { "type": "array", "items": { "type": "object", "required": ["date", "clicks"], "properties": { "date": { "type": "string", "format": "date" }, "clicks": { "type": "integer" } } } },
          "byCountry": { "type": "array", "items": { "type": "object", "properties": { "country": { "type": "string" }, "clicks": { "type": "integer" } } } },
          "byRegion": { "type": "array", "items": { "type": "object", "properties": { "region": { "type": "string" }, "country": { "type": "string" }, "clicks": { "type": "integer" } } } },
          "byCity": { "type": "array", "items": { "type": "object", "properties": { "city": { "type": "string" }, "country": { "type": "string" }, "clicks": { "type": "integer" } } } },
          "byDevice": { "type": "array", "items": { "type": "object", "properties": { "device": { "type": "string" }, "clicks": { "type": "integer" } } } },
          "byOs": { "type": "array", "items": { "type": "object", "properties": { "os": { "type": "string" }, "clicks": { "type": "integer" } } } },
          "byBrowser": { "type": "array", "items": { "type": "object", "properties": { "browser": { "type": "string" }, "clicks": { "type": "integer" } } } },
          "byReferer": { "type": "array", "items": { "type": "object", "properties": { "referer": { "type": "string" }, "clicks": { "type": "integer" } } } },
          "byLanguage": { "type": "array", "items": { "type": "object", "properties": { "language": { "type": "string" }, "clicks": { "type": "integer" } } } },
          "byTimezone": { "type": "array", "items": { "type": "object", "properties": { "timezone": { "type": "string" }, "clicks": { "type": "integer" } } } },
          "hourlyHeatmap": { "type": "array", "items": { "type": "object", "properties": { "day": { "type": "integer", "minimum": 0, "maximum": 6 }, "hour": { "type": "integer", "minimum": 0, "maximum": 23 }, "clicks": { "type": "integer" } } } }
        }
      },
      "ClickEvent": {
        "type": "object",
        "required": ["timestamp"],
        "properties": {
          "timestamp": { "type": "string", "format": "date-time" },
          "country": { "type": "string" },
          "region": { "type": "string" },
          "city": { "type": "string" },
          "device": { "type": "string" },
          "browser": { "type": "string" },
          "os": { "type": "string" },
          "referer": { "type": "string" },
          "isBot": { "type": "boolean" },
          "slug": { "type": "string", "description": "Only present on the cross-account /api/analytics/events tail." }
        }
      }
    }
  }
}
