{
  "openapi": "3.1.0",
  "info": {
    "title": "302.sh API",
    "version": "2026-07-31",
    "description": "Public REST API for 302.sh, a lightweight short-link service. Public API tokens are available on Creator and higher plans and are created at https://302.sh/dashboard/tokens. 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**Workspaces:** tokens are bound to the workspace selected when they are created. Existing tokens stay with the original default workspace. Every request checks current membership and seat access. Removing a member revokes their workspace tokens permanently. A browser may select a workspace using `X-Workspace-Id`; a bearer token cannot change workspace with this header (`403 token_workspace_mismatch`). Workspace management and web billing require cookie sessions. The documented native billing endpoints require first-party native tokens; purchases are owner-only.\n\n**Scopes:** a token reads everything its bound workspace owns by default. Public-token requests that publish hosted content — the `/api/bio` write routes — additionally require the `bio:write` scope, chosen when the token is created. First-party native credentials carry no public API scopes; the server separately permits Bio list/create/check/edit/delete content routes and read-only Bio analytics/CSV export. Native credentials cannot access Bio domain attachment, unsaved HTML previews or forwarding credentials. Tokens minted before scopes existed carry none, and a token without the required scope gets `403 {\"error\":\"token_scope_required\",\"scope\":\"bio:write\"}`. Scopes cannot be added to an existing token; mint a new one.\n\n**Rate limits:** public tokens are capped per token per UTC day (Creator 10,000; Pro 100,000; Business 500,000). A 429 response includes Retry-After. Every bearer-authenticated route charges this quota, including one refused for a missing scope. The separate soft per-month analytics quota only affects which events are written to analytics; reaching it does not itself stop redirects. Deletion, expiration, owner-set restrictions and safety enforcement can still prevent a redirect.\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" },
    { "name": "bio", "description": "Hosted link-in-bio pages. Public-token writes require `bio:write`; native credentials have a separate content and read-only analytics route allowlist. Reads do not require a public scope. `POST /api/bio/preview` (renders an unsaved draft as HTML for the dashboard editor) and `PATCH /api/bio/{id}/domain` are reachable with the same scope but are dashboard-internal and not part of the stable contract. Conversion forwarding (`/api/bio/{id}/forwarding`, which attaches an ad-platform credential to a page) is deliberately absent from this API entirely: it accepts cookie sessions only, so a leaked personal token cannot point our servers at a credential-testing target. Configure it in the dashboard." },
    { "name": "native-auth", "description": "Internal first-party mobile authentication bridge" }
  ],
  "paths": {
    "/api/billing/native": {
      "get": {
        "operationId": "nativeBillingRead",
        "tags": [
          "Native billing"
        ],
        "summary": "Read workspace billing",
        "description": "Only first-party native credentials. Public API tokens and browser sessions are rejected. Requires backend billing configuration; purchases additionally require the sales flag. No Family Sharing or automatic transfers.",
        "responses": {
          "200": {
            "description": "Current server billing state",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NativeBillingSnapshot"
                }
              }
            }
          },
          "403": {
            "description": "Native authentication or workspace owner required"
          },
          "409": {
            "description": "Existing subscription, identity/workspace conflict, or checkout in progress"
          },
          "503": {
            "description": "Configuration missing or reconciliation pending; retry without purchasing again"
          }
        }
      }
    },
    "/api/billing/native/prepare": {
      "post": {
        "operationId": "nativeBillingPrepare",
        "tags": [
          "Native billing"
        ],
        "summary": "Reserve an owner-only Apple purchase",
        "description": "Only first-party native credentials. Public API tokens and browser sessions are rejected. Requires backend billing configuration; purchases additionally require the sales flag. No Family Sharing or automatic transfers.",
        "responses": {
          "200": {
            "description": "Current server billing state",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NativeBillingSnapshot"
                }
              }
            }
          },
          "403": {
            "description": "Native authentication or workspace owner required"
          },
          "409": {
            "description": "Existing subscription, identity/workspace conflict, or checkout in progress"
          },
          "503": {
            "description": "Configuration missing or reconciliation pending; retry without purchasing again"
          }
        }
      }
    },
    "/api/billing/native/sync": {
      "post": {
        "operationId": "nativeBillingSync",
        "tags": [
          "Native billing"
        ],
        "summary": "Reconcile verified Apple subscriptions",
        "description": "Only first-party native credentials. Public API tokens and browser sessions are rejected. Requires backend billing configuration; purchases additionally require the sales flag. No Family Sharing or automatic transfers.",
        "responses": {
          "200": {
            "description": "Current server billing state",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NativeBillingSnapshot"
                }
              }
            }
          },
          "403": {
            "description": "Native authentication or workspace owner required"
          },
          "409": {
            "description": "Existing subscription, identity/workspace conflict, or checkout in progress"
          },
          "503": {
            "description": "Configuration missing or reconciliation pending; retry without purchasing again"
          }
        }
      }
    },
    "/api/billing/native/cancel": {
      "post": {
        "operationId": "nativeBillingCancel",
        "tags": [
          "Native billing"
        ],
        "summary": "Release an explicitly canceled purchase reservation",
        "description": "Only first-party native credentials. Public API tokens and browser sessions are rejected. Requires backend billing configuration; purchases additionally require the sales flag. No Family Sharing or automatic transfers.",
        "responses": {
          "200": {
            "description": "Reservation released",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Native authentication or workspace owner required"
          },
          "409": {
            "description": "Existing subscription, identity/workspace conflict, or checkout in progress"
          },
          "503": {
            "description": "Configuration missing or reconciliation pending; retry without purchasing again"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "checkoutId"
                ],
                "properties": {
                  "checkoutId": {
                    "type": "string"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/billing/native/identity": {
      "post": {
        "operationId": "nativeBillingIdentity",
        "tags": [
          "Native billing"
        ],
        "summary": "Provision an identified SDK customer without binding a workspace",
        "description": "Only first-party native credentials. Public API tokens and browser sessions are rejected. Requires backend billing configuration; purchases additionally require the sales flag. No Family Sharing or automatic transfers.",
        "responses": {
          "200": {
            "description": "Current server billing state",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NativeBillingSnapshot"
                }
              }
            }
          },
          "403": {
            "description": "Native authentication or workspace owner required"
          },
          "409": {
            "description": "Existing subscription, identity/workspace conflict, or checkout in progress"
          },
          "503": {
            "description": "Configuration missing or reconciliation pending; retry without purchasing again"
          }
        }
      }
    },
    "/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" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "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" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "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\nThis is a basic mapping export, not a complete backup. Rows include slug, target, expiry and comment, but omit the hostname, routing configuration and analytics history. Import creates links on the default shared host; preserve exact host/path mappings separately before migration. Password plaintext is never exported: `passwordProtected: true` indicates that the caller must re-supply the password when importing.",
        "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": "Pro+ only. 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/BulkImportRow" } }
                }
              }
            }
          }
        },
        "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", "description": "Per-row machine code, e.g. `unsafe_target`, `blocked_target_domain` (destination domain blocked after an abuse review), `link_limit_reached`, `slug_taken`." } } } }
                  }
                }
              }
            }
          },
          "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"] } },
        {"name": "startAt", "in": "query", "schema": {"type": "integer"}, "description": "Inclusive Unix seconds. Send with endAt."},
        {"name": "endAt", "in": "query", "schema": {"type": "integer"}, "description": "Inclusive Unix seconds. Send with startAt; clamped to retention."},
        {"name": "tz", "in": "query", "schema": {"type": "string"}, "description": "IANA timezone for daily buckets; defaults to UTC."}
      ],
      "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" }
        }
      }
    },
    "/api/bio": {
      "get": {
        "tags": ["bio"],
        "summary": "List your bio pages",
        "description": "No scope required — listing your own pages publishes nothing.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["pages", "tier", "bioPageLimit"],
                  "properties": {
                    "pages": { "type": "array", "items": { "$ref": "#/components/schemas/BioPage" } },
                    "tier": { "type": "string", "enum": ["free", "creator", "pro", "business"] },
                    "bioPageLimit": { "type": "integer", "description": "Hard page cap for the caller's tier; mirror of shared/plans.ts." }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["bio"],
        "summary": "Create a bio page",
        "description": "Requires `bio:write` for public API tokens, or a first-party native credential explicitly allowed on this content route. The page is created on the shared bio host and is unpublished unless `published: true` is sent. Rate limited per account per day and per IP per hour, independently of the token's API quota; a rejected attempt still counts.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateBioPageBody" } } }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": { "application/json": { "schema": { "type": "object", "required": ["page"], "properties": { "page": { "$ref": "#/components/schemas/BioPage" } } } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/TokenScopeRequired" },
          "409": {
            "description": "The handle is taken (`handle_taken`) or held by a 90-day reservation left by another account's rename (`handle_reserved`, with `availableAt`).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "handle_reserved", "availableAt": 1793000000000 } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/bio/check": {
      "get": {
        "tags": ["bio"],
        "summary": "Check whether a handle is available",
        "description": "Requires `bio:write` for public API tokens, or a first-party native credential explicitly allowed on this content route. Scoped despite writing nothing: this is a squatting oracle, and answering \"is `nike` free?\" at machine speed is worth more to an attacker than to a legitimate script. Always 200 — availability is reported in the body, never as a status code — and separately capped at 100 lookups per account per day.",
        "parameters": [
          { "name": "handle", "in": "query", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["handle", "available", "reason"],
                  "properties": {
                    "handle": { "type": "string", "description": "The normalized (lowercased, trimmed) handle that was checked." },
                    "available": { "type": "boolean" },
                    "reason": { "type": ["string", "null"], "description": "Why not, when unavailable: `handle_taken`, `handle_reserved`, or a shape error such as `slug_too_short` / `slug_reserved`." },
                    "availableAt": { "type": "integer", "description": "Present on `handle_reserved`: Unix milliseconds at which the reservation lapses." },
                    "minLength": { "type": "integer", "description": "Present on a shape error: the caller's tier minimum." },
                    "upgradeTo": { "type": ["string", "null"], "description": "Present on a shape error: cheapest tier that allows a handle this short." }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/TokenScopeRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/bio/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The page's id (NOT the handle). Returned by POST /api/bio and GET /api/bio." }
      ],
      "patch": {
        "tags": ["bio"],
        "summary": "Update a bio page",
        "description": "Requires `bio:write` for public API tokens, or a first-party native credential explicitly allowed on this content route. Only included fields are updated; `blocks` and `socials` replace the whole array when sent. `avatarUrl` is never accepted — the avatar is always a copy of the account's sign-in picture. Renaming the handle leaves a 301 at the old one and reserves it for 90 days.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateBioPageBody" } } }
        },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "required": ["page"], "properties": { "page": { "$ref": "#/components/schemas/BioPage" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "403": { "$ref": "#/components/responses/TokenScopeRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["bio"],
        "summary": "Delete a bio page",
        "description": "Requires `bio:write` for public API tokens, or a first-party native credential explicitly allowed on this content route. A page removed by moderation cannot be deleted through the API.",
        "responses": {
          "200": { "description": "Deleted", "content": { "application/json": { "schema": { "type": "object", "required": ["ok"], "properties": { "ok": { "type": "boolean", "enum": [true] } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/TokenScopeRequired" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/analytics/bio/{handle}": {
      "parameters": [
        { "name": "handle", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[0-9A-Za-z_-]{3,32}$" }, "description": "Bio analytics are keyed by HANDLE, not by page id." },
        { "name": "includeBots", "in": "query", "schema": { "type": "string", "enum": ["1"] } },
        { "name": "startAt", "in": "query", "schema": { "type": "integer" }, "description": "Inclusive Unix seconds. Send with endAt." },
        { "name": "endAt", "in": "query", "schema": { "type": "integer" }, "description": "Inclusive Unix seconds. Send with startAt; clamped to retention." },
        { "name": "tz", "in": "query", "schema": { "type": "string", "default": "UTC" }, "description": "IANA timezone used to bucket days and the hourly heatmap." }
      ],
      "get": {
        "tags": ["bio", "analytics"],
        "summary": "90-day analytics for one bio page",
        "description": "No scope required. Public and first-party native tokens can read analytics for pages in their bound workspace. Page views and outbound block presses are reported separately rather than summed — the ratio between them is the number an owner acts on.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["views", "clicks", "byBlock"],
                  "properties": {
                    "views": { "$ref": "#/components/schemas/BioBreakdown", "description": "Page views." },
                    "clicks": { "$ref": "#/components/schemas/BioBreakdown", "description": "Outbound presses on blocks." },
                    "byBlock": { "type": "array", "items": { "type": "object", "required": ["blockId", "clicks"], "properties": { "blockId": { "type": "string" }, "clicks": { "type": "integer" } } }, "description": "Includes ids of blocks that have since been deleted, so the parts still sum to the page total." }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "description": "No bio page with that handle belongs to the caller.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "bio_page_not_found" } } } }
        }
      }
    },
    "/api/analytics/bio/{handle}/export": {
      "parameters": [
        { "name": "handle", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[0-9A-Za-z_-]{3,32}$" } },
        { "name": "type", "in": "query", "schema": { "type": "string", "enum": ["timeseries", "blocks", "events"], "default": "timeseries" }, "description": "`timeseries` (default) is one row per day as `date,views,clicks` over the union of both series. `blocks` is `block_id,label,url,clicks`. `events` is one row per hit, with a `kind` column telling views and presses apart. An unrecognised value falls back to `timeseries` rather than erroring — an archive request should not fail over a typo." },
        { "name": "includeBots", "in": "query", "schema": { "type": "string", "enum": ["1"] } },
        { "name": "startAt", "in": "query", "schema": { "type": "integer" }, "description": "Inclusive Unix seconds. Send with endAt." },
        { "name": "endAt", "in": "query", "schema": { "type": "integer" }, "description": "Inclusive Unix seconds. Send with startAt; clamped to retention." },
        { "name": "tz", "in": "query", "schema": { "type": "string", "default": "UTC" } }
      ],
      "get": {
        "tags": ["bio", "analytics"],
        "summary": "Download bio-page analytics as CSV",
        "description": "No scope required. Public and first-party native tokens can read analytics for pages in their bound workspace. Analytics retention is 90 days and cannot be extended on any plan, so export is the only way to keep a longer record — and a daily roll-up cannot be re-disaggregated later, which is why the per-block and per-event shapes are offered too.",
        "responses": {
          "200": {
            "description": "CSV file",
            "content": { "text/csv": { "schema": { "type": "string" } } },
            "headers": {
              "Content-Disposition": { "schema": { "type": "string", "example": "attachment; filename=\"bio-ada-daily.csv\"" } }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/native-auth/apple": {
      "post": {
        "tags": ["native-auth"],
        "summary": "Exchange a native Sign in with Apple credential for a Better Auth session",
        "description": "First-party iOS endpoint. The authorization code is single-use and exchanged server-side with Apple. A successful response sets the temporary Better Auth session cookie used by POST /api/native-auth/token; the app never persists this session.",
        "x-internal": true,
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/NativeAppleAuthBody" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Better Auth session created. The response carries a Set-Cookie session header; the JSON body is owned by Better Auth and should be treated as opaque by the iOS client."
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": {
            "description": "Apple rejected the authorization code or Better Auth rejected the verified Apple identity.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": {
            "description": "Per-IP native authentication limit exceeded.",
            "headers": { "Retry-After": { "schema": { "type": "integer" } } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "500": {
            "description": "The server could not persist the encrypted Apple revocation credential.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "tok_<base62, 32 chars>",
        "description": "Creator+ only. Create a token at https://302.sh/dashboard/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).\n\nA token may additionally carry capability scopes, chosen at creation. The only scope today is `bio:write`, required by the `/api/bio` write routes. OpenAPI's `http`/`bearer` scheme has no formal scope list (only OAuth2 and OpenID Connect do), so each operation states its requirement in its description and declares the `403 token_scope_required` response."
      }
    },
    "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" } } }
      },
      "Forbidden": {
        "description": "Refused by an abuse control. Currently one code: `blocked_target_domain` — a destination whose registrable domain we blocked after an abuse review (see docs/SECURITY-URL-SAFETY.md Layer 4g). The block is scoped to our shared short domains, so the same destination still works on a custom domain you own.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "blocked_target_domain", "domain": "evil.example", "customDomainAllowed": true, "appealable": true } } }
      },
      "BadRequest": {
        "description": "Validation failed. `error` is a stable machine code (e.g. `target_url_invalid`, `slug_invalid`, `expiresAt_in_past`, `unsafe_target` — target flagged by Google Safe Browsing; creation/edit refused).",
        "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`, `monthly_link_limit_reached` (Free: 5 new links per UTC calendar month, 50 owned; deletions do not refund monthly usage), `password_requires_creator`, `split_requires_pro`, or `bio_page_limit_reached`.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "TokenScopeRequired": {
        "description": "The token is valid but does not carry the scope this route needs. Not a 401 (re-authenticating changes nothing) and not a 402 (money does not clear it) — scopes are fixed at mint time, so the fix is to create a new token with the scope ticked.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "token_scope_required", "scope": "bio:write" } } }
      },
      "RateLimited": {
        "description": "A rate limit was hit — either the token's per-day API quota (`rate_limited`) or a per-resource abuse limit such as bio-page creation. Always carries Retry-After.",
        "headers": { "Retry-After": { "schema": { "type": "integer" } } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "rate_limited", "limit": 10000, "period": "day" } } }
      }
    },
    "schemas": {
      "NativeBillingSnapshot": {
        "type": "object",
        "description": "Native credential only. Server-authoritative workspace access; times are Unix milliseconds. appUserID is a server-generated UUID permanently bound to the purchaser and original workspace. Never accept client entitlement assertions.",
        "required": [
          "workspaceId",
          "tier",
          "canPurchase",
          "canManage",
          "reason",
          "appUserID",
          "serverTime",
          "features",
          "sources",
          "products",
          "isBound"
        ],
        "properties": {
          "workspaceId": {
            "type": "string"
          },
          "tier": {
            "type": "string"
          },
          "canPurchase": {
            "type": "boolean"
          },
          "canManage": {
            "type": "boolean"
          },
          "reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "appUserID": {
            "type": [
              "string",
              "null"
            ]
          },
          "serverTime": {
            "type": "integer"
          },
          "checkoutId": {
            "type": "string",
            "description": "Owner purchase reservation. Release only after explicit StoreKit user cancellation."
          },
          "result": {
            "enum": [
              "synced",
              "busy",
              "deleted"
            ]
          },
          "features": {
            "type": "object",
            "additionalProperties": {
              "type": "boolean"
            }
          },
          "sources": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "provider": {
                  "enum": [
                    "stripe",
                    "apple"
                  ]
                },
                "tier": {
                  "type": "string"
                },
                "status": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "validUntil": {
                  "type": [
                    "integer",
                    "null"
                  ]
                },
                "renews": {
                  "type": "boolean"
                },
                "active": {
                  "type": "boolean"
                }
              },
              "required": [
                "provider",
                "tier",
                "status",
                "validUntil",
                "renews",
                "active"
              ]
            }
          },
          "products": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "tier": {
                  "enum": [
                    "creator",
                    "pro",
                    "business"
                  ]
                },
                "interval": {
                  "enum": [
                    "month",
                    "year"
                  ]
                },
                "limits": {
                  "type": "object",
                  "additionalProperties": true
                },
                "features": {
                  "type": "object",
                  "additionalProperties": {
                    "type": "boolean"
                  }
                }
              },
              "required": [
                "id",
                "tier",
                "interval",
                "limits",
                "features"
              ]
            }
          },
          "isBound": {
            "type": "boolean",
            "description": "False for a provisioned SDK identity until purchase preparation permanently binds it to this workspace."
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": { "type": "string", "description": "Stable machine code. UI-facing copy comes from the client dictionary." },
          "url": { "type": "string", "description": "Present on `unsafe_target`: the specific flagged destination URL (may be a non-primary routing field, not the main target)." },
          "threatType": { "type": "string", "description": "Present on `unsafe_target`: the threat category — `SOCIAL_ENGINEERING`, `MALWARE`, or `UNWANTED_SOFTWARE`." },
          "scope": { "type": "string", "description": "Present on `token_scope_required`: the scope the token is missing." },
          "availableAt": { "type": "integer", "description": "Present on `handle_reserved`: Unix milliseconds at which the reserved bio handle can be claimed." },
          "retryAfterSec": { "type": "integer", "description": "Present on `bio_rate_limited`: seconds to wait, mirroring the Retry-After header." },
          "domain": { "type": "string", "description": "Present on `blocked_target_domain`: the blocked registrable domain." },
          "customDomainAllowed": { "type": "boolean", "description": "Present on `blocked_target_domain`: the block is scoped to our shared hosts, so the same destination is still shortenable on a custom domain the caller owns." },
          "appealable": { "type": "boolean", "description": "Present on `blocked_target_domain`: the block can be appealed via POST /api/appeal with kind=\"domain\"." }
        }
      },
      "NativeAppleAuthBody": {
        "type": "object",
        "required": ["authorizationCode", "nonce"],
        "properties": {
          "authorizationCode": { "type": "string", "maxLength": 2048, "description": "Single-use code from ASAuthorizationAppleIDCredential." },
          "nonce": { "type": "string", "pattern": "^[0-9a-fA-F]{64}$", "description": "SHA-256 digest sent in the native Apple authorization request." },
          "user": {
            "type": "object",
            "description": "One-time profile fields supplied by Apple on first authorization. Omit when unavailable.",
            "properties": {
              "firstName": { "type": "string", "maxLength": 128 },
              "lastName": { "type": "string", "maxLength": 128 },
              "email": { "type": "string", "format": "email", "maxLength": 320 }
            }
          }
        }
      },
      "Link": {
        "type": "object",
        "required": ["id", "slug", "target", "created_at", "updated_at"],
        "properties": {
          "desktop_target": {"type": ["string", "null"], "format": "uri"},
          "tablet_target": {"type": ["string", "null"], "format": "uri"},
          "bot_target": {"type": ["string", "null"], "format": "uri"},
          "schedule": {"type": ["object", "null"], "description": "Smart-routing time window; timestamps are Unix milliseconds. Timezone is a display hint. Creator+ when changed.", "properties": {"startsAt": {"type": "integer"}, "endsAt": {"type": "integer"}, "beforeStartUrl": {"type": "string", "format": "uri"}, "afterEndUrl": {"type": "string", "format": "uri"}, "timezone": {"type": "string"}}},
          "recurring": {"type": ["array", "null"], "maxItems": 20, "items": {"type": "object", "required": ["days", "hours", "url"], "properties": {"days": {"type": "string", "description": "Day list or range, e.g. mon,tue or mon-fri."}, "hours": {"type": "string", "description": "Hour range, e.g. 9-17; overnight ranges are supported."}, "tz": {"type": "string"}, "url": {"type": "string", "format": "uri"}}}},
          "referer": {"type": ["array", "null"], "maxItems": 20, "items": {"type": "object", "required": ["pattern", "url"], "properties": {"pattern": {"type": "string"}, "url": {"type": "string", "format": "uri"}}}},
          "lang": {"type": ["object", "null"], "maxProperties": 50, "additionalProperties": {"type": "string", "format": "uri"}},
          "split": {"type": ["array", "null"], "minItems": 2, "maxItems": 10, "description": "Pro+ weighted routing. Omit unchanged values in PATCH.", "items": {"type": "object", "required": ["weight", "url"], "properties": {"weight": {"type": "number", "exclusiveMinimum": 0}, "url": {"type": "string", "format": "uri"}, "label": {"type": "string"}}}},
          "click_limit": {"type": ["object", "null"], "description": "Business+ total click cap. Only a changed value resets the remaining click budget.", "required": ["max"], "properties": {"max": {"type": "integer", "minimum": 1, "maximum": 10000000}, "fallbackUrl": {"type": "string", "format": "uri"}}},
          "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 segment. Build the public URL from the returned host and slug: `https://{host}/{slug}`." },
          "host": { "type": "string", "description": "Hostname serving this link. Use this value rather than assuming the API hostname is also the short-link hostname." },
          "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. Creator+ only." },
          "created_at": { "type": "integer", "description": "Unix milliseconds." },
          "updated_at": { "type": "integer", "description": "Unix milliseconds." }
        }
      },
      "CreateLinkBody": {
        "type": "object",
        "required": ["target"],
        "properties": {
          "desktop": {"type": "string", "format": "uri"},
          "tablet": {"type": "string", "format": "uri"},
          "bot": {"type": "string", "format": "uri"},
          "schedule": {"type": "object", "description": "Smart-routing time window; timestamps are Unix milliseconds. Timezone is a display hint. Creator+ when changed.", "properties": {"startsAt": {"type": "integer"}, "endsAt": {"type": "integer"}, "beforeStartUrl": {"type": "string", "format": "uri"}, "afterEndUrl": {"type": "string", "format": "uri"}, "timezone": {"type": "string"}}},
          "recurring": {"type": "array", "maxItems": 20, "items": {"type": "object", "required": ["days", "hours", "url"], "properties": {"days": {"type": "string", "description": "Day list or range, e.g. mon,tue or mon-fri."}, "hours": {"type": "string", "description": "Hour range, e.g. 9-17; overnight ranges are supported."}, "tz": {"type": "string"}, "url": {"type": "string", "format": "uri"}}}},
          "referer": {"type": "array", "maxItems": 20, "items": {"type": "object", "required": ["pattern", "url"], "properties": {"pattern": {"type": "string"}, "url": {"type": "string", "format": "uri"}}}},
          "lang": {"type": "object", "maxProperties": 50, "additionalProperties": {"type": "string", "format": "uri"}},
          "split": {"type": "array", "minItems": 2, "maxItems": 10, "description": "Pro+ weighted routing. Omit unchanged values in PATCH.", "items": {"type": "object", "required": ["weight", "url"], "properties": {"weight": {"type": "number", "exclusiveMinimum": 0}, "url": {"type": "string", "format": "uri"}, "label": {"type": "string"}}}},
          "clickLimit": {"type": "object", "description": "Business+ total click cap. Only a changed value resets the remaining click budget.", "required": ["max"], "properties": {"max": {"type": "integer", "minimum": 1, "maximum": 10000000}, "fallbackUrl": {"type": "string", "format": "uri"}}},
          "target": { "type": "string", "format": "uri" },
          "slug": { "type": "string", "description": "Optional custom slug. Auto-generated if omitted." },
          "host": { "type": ["string", "null"], "default": "3go.to", "description": "Optional short-link hostname. Omitted, null or empty uses the default shared host. Accepts a shared host or an active verified custom domain owned by this workspace and allowed by its plan. Custom hostnames are trimmed and lowercased." },
          "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": "Creator+ only. The legacy stable error code for a Free account is cloak_requires_pro." }
        }
      },
      "BulkImportRow": {
        "type": "object",
        "description": "Basic fields accepted for each imported link. Imports always use the default shared host; host and routing fields are not supported and are ignored if supplied.",
        "required": ["target"],
        "properties": {
          "target": { "type": "string", "format": "uri", "description": "Destination using http or https." },
          "slug": { "type": "string", "description": "Optional custom slug. Auto-generated if omitted." },
          "expiresAt": { "type": ["number", "null"], "description": "Future Unix milliseconds; omitted or null means no expiration." },
          "comment": { "type": ["string", "null"], "maxLength": 2048 },
          "password": { "type": "string", "description": "Hashed server-side before storage; never echoed." }
        }
      },
      "UpdateLinkBody": {
        "type": "object",
        "description": "All fields optional. `null` clears a nullable field; omitting keeps current value.",
        "properties": {
          "slug": {"type": "string", "description": "Rename the slug. The old redirect is removed; confirm before changing a circulated URL."},
          "desktop": {"type": ["string", "null"], "format": "uri"},
          "tablet": {"type": ["string", "null"], "format": "uri"},
          "bot": {"type": ["string", "null"], "format": "uri"},
          "schedule": {"type": ["object", "null"], "description": "Smart-routing time window; timestamps are Unix milliseconds. Timezone is a display hint. Creator+ when changed.", "properties": {"startsAt": {"type": "integer"}, "endsAt": {"type": "integer"}, "beforeStartUrl": {"type": "string", "format": "uri"}, "afterEndUrl": {"type": "string", "format": "uri"}, "timezone": {"type": "string"}}},
          "recurring": {"type": ["array", "null"], "maxItems": 20, "items": {"type": "object", "required": ["days", "hours", "url"], "properties": {"days": {"type": "string", "description": "Day list or range, e.g. mon,tue or mon-fri."}, "hours": {"type": "string", "description": "Hour range, e.g. 9-17; overnight ranges are supported."}, "tz": {"type": "string"}, "url": {"type": "string", "format": "uri"}}}},
          "referer": {"type": ["array", "null"], "maxItems": 20, "items": {"type": "object", "required": ["pattern", "url"], "properties": {"pattern": {"type": "string"}, "url": {"type": "string", "format": "uri"}}}},
          "lang": {"type": ["object", "null"], "maxProperties": 50, "additionalProperties": {"type": "string", "format": "uri"}},
          "split": {"type": ["array", "null"], "minItems": 2, "maxItems": 10, "description": "Pro+ weighted routing. Omit unchanged values in PATCH.", "items": {"type": "object", "required": ["weight", "url"], "properties": {"weight": {"type": "number", "exclusiveMinimum": 0}, "url": {"type": "string", "format": "uri"}, "label": {"type": "string"}}}},
          "clickLimit": {"type": ["object", "null"], "description": "Business+ total click cap. Only a changed value resets the remaining click budget.", "required": ["max"], "properties": {"max": {"type": "integer", "minimum": 1, "maximum": 10000000}, "fallbackUrl": {"type": "string", "format": "uri"}}},
          "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": "Creator+ only on enable; Free users can always disable it." }
        }
      },
      "AnalyticsSummary": {
        "type": "object",
        "required": ["slug", "range", "totalClicks", "uniqueVisitors", "uniqueVisitorsPartial", "botClicks", "timeseries"],
        "properties": {
          "slug": { "type": "string" },
          "range": {
            "oneOf": [
              {
                "type": "object",
                "required": ["fromDays"],
                "properties": { "fromDays": { "type": "integer", "example": 90 } },
                "additionalProperties": false
              },
              {
                "type": "object",
                "required": ["startAt", "endAt"],
                "properties": {
                  "startAt": { "type": "integer", "description": "Inclusive Unix timestamp in seconds." },
                  "endAt": { "type": "integer", "description": "Inclusive Unix timestamp in seconds." }
                },
                "additionalProperties": false
              }
            ]
          },
          "totalClicks": { "type": "integer" },
          "uniqueVisitors": { "type": "integer", "description": "Sampling-weighted distinct visitor IPs for v3 rows plus a distinct-country compatibility fallback for legacy rows." },
          "uniqueVisitorsPartial": { "type": "boolean", "description": "True when uniqueVisitors includes the legacy country fallback and is not a strict full-range distinct count." },
          "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" } } } }
        }
      },
      "BioBlock": {
        "type": "object",
        "description": "One row on a bio page. Two variants, discriminated by `kind`: a link (the default when `kind` is omitted) requires `url`; a `heading` is a section label and carries no url and no click tracking.",
        "required": ["label"],
        "properties": {
          "id": { "type": "string", "description": "Assigned by the server on first save. It is the click-tracking key, so preserve it across edits — a regenerated id starts that block's analytics over." },
          "kind": { "type": "string", "enum": ["link", "heading"], "description": "Omit for a link." },
          "label": { "type": "string", "maxLength": 60 },
          "url": { "type": "string", "format": "uri", "description": "Required on a link block. https and mailto only — http is refused." },
          "emoji": { "type": "string", "description": "Up to 8 code points, rendered before the label." },
          "featured": { "type": "boolean", "description": "Emphasise this block. At most one per page; the server keeps the first." },
          "hidden": { "type": "boolean", "description": "Keep the block on the page's record but do not render it. Hidden blocks are still content-screened." },
          "startsAt": { "type": "integer", "description": "Unix milliseconds. Before this, the block is not rendered." },
          "endsAt": { "type": "integer", "description": "Unix milliseconds. After this, the block is not rendered." },
          "route": { "$ref": "#/components/schemas/BioBlockRoute" }
        }
      },
      "BioBlockRoute": {
        "type": "object",
        "description": "Smart routing for one link block: the tracked hop /{handle}/c/{blockId} picks a destination per visitor instead of always serving `url`. Resolution order is recurring rule, then bot/tablet/iOS/Android/desktop, then geo (most specific key wins), then language; anything unmatched falls back to the block's own `url`. Creator plan and up — but the gate is on the CHANGE, so a downgraded account keeps serving what it already stored and can always remove it. Every URL here is safety-screened on save exactly like `url`. Omit or send an empty object to clear.\n\nStricter than link routing in two ways: `https:` only (no `http:`, no `mailto:` — a bio destination that the hop would refuse is a setting that can never serve), and a page-wide cap of 100 routing destinations summed across all blocks.\n\n`referer` is REFUSED, not unimplemented: a bio page sends `Referrer-Policy: no-referrer`, so the click carries no referrer to match and the rule could never fire. `split`, `schedule`, `clickLimit`, password and cloak are refused too — use the block's own `startsAt`/`endsAt` for scheduling, and paste a password-protected or cloaked short link as the block's `url` for the rest.",
        "properties": {
          "ios": { "type": "string", "format": "uri", "description": "iOS phones." },
          "android": { "type": "string", "format": "uri", "description": "Android phones." },
          "tablet": { "type": "string", "format": "uri", "description": "Tablets. Checked before the phone slots." },
          "desktop": { "type": "string", "format": "uri", "description": "Desktop browsers." },
          "bot": { "type": "string", "format": "uri", "description": "Crawlers and bots. Purely a routing override; it does not change how the page is rendered to them." },
          "geo": { "type": "object", "additionalProperties": { "type": "string", "format": "uri" }, "description": "Keys are `US`, `US-CA` or `US-CA-San-Francisco` — country, country-region, or country-region-city, normalised on write. Most specific match wins. At most 50 entries." },
          "lang": { "type": "object", "additionalProperties": { "type": "string", "format": "uri" }, "description": "Keys are loose BCP-47 tags (`en`, `pt-br`, `zh-cn`), matched against the visitor's top-weighted Accept-Language and then against its primary subtag. At most 50 entries." },
          "recurring": { "type": "array", "description": "Time-of-day rules, evaluated first. Not exposed in the dashboard editor yet; the editor preserves whatever the API stored.", "items": { "type": "object", "required": ["days", "hours", "url"], "properties": { "days": { "type": "string", "description": "`mon-fri` or `sat,sun`." }, "hours": { "type": "string", "description": "`09-17`, 24h, end-exclusive. Wraps midnight." }, "tz": { "type": "string", "description": "IANA timezone. Defaults to UTC." }, "url": { "type": "string", "format": "uri" } } } }
        }
      },
      "BioSocial": {
        "type": "object",
        "required": ["platform", "url"],
        "properties": {
          "platform": { "type": "string", "enum": ["x", "instagram", "tiktok", "youtube", "github", "linkedin", "threads", "bluesky", "mastodon", "twitch", "spotify", "substack", "email", "website"] },
          "url": { "type": "string", "format": "uri" }
        }
      },
      "BioPage": {
        "type": "object",
        "required": ["id", "handle", "canonicalHost", "url", "blocks", "socials", "published", "createdAt", "updatedAt"],
        "properties": {
          "indexableStatus": {"type": "object", "description": "Server verdict for the indexing request. off means not requested; other states may keep a request pending or refused without changing indexable.", "required": ["state"], "properties": {"state": {"type": "string"}, "eligibleAt": {"type": "integer"}, "upgradeTo": {"type": "string", "enum": ["free", "creator", "pro", "business"]}}},
          "id": { "type": "string", "description": "Opaque 12-char identifier, used as the path parameter on /api/bio/:id." },
          "handle": { "type": "string", "description": "Lowercase. Also the key for /api/analytics/bio/{handle}." },
          "canonicalHost": { "type": "string", "description": "The host this page is authoritative on — the shared bio host, or the caller's own attached hostname." },
          "customHost": { "type": ["string", "null"], "description": "The attached hostname, or null. Equal to canonicalHost once attached." },
          "url": { "type": "string", "format": "uri", "description": "Where the page actually serves. A page on its owner's own hostname lives at that host's ROOT, not under /{handle}, so do not build this by concatenation." },
          "displayName": { "type": ["string", "null"] },
          "bio": { "type": ["string", "null"] },
          "avatarUrl": { "type": ["string", "null"], "format": "uri", "description": "Always a copy of the account's sign-in picture. Read-only — the API never accepts one." },
          "theme": { "type": "string", "enum": ["porcelain", "aurora", "mosaic", "graphite", "forest", "sandstone", "terminal", "brutal", "editorial"] },
          "themeMode": { "type": "string", "enum": ["light", "dark", "system"] },
          "lang": { "type": "string", "enum": ["en", "es", "ja", "pt", "de", "fr", "ko"], "description": "Language of the page's own chrome (the report link, the badge). Not a translation of your content." },
          "blocks": { "type": "array", "items": { "$ref": "#/components/schemas/BioBlock" } },
          "socials": { "type": "array", "items": { "$ref": "#/components/schemas/BioSocial" } },
          "utm": { "type": ["object", "null"], "description": "Campaign parameters stamped onto every outbound destination, or null. Re-normalised on the way out, so a stored value that no longer validates reads as null.", "properties": { "source": { "type": "string" }, "medium": { "type": "string" }, "campaign": { "type": "string" } } },
          "published": { "type": "boolean" },
          "indexable": { "type": "boolean", "description": "Whether search engines may index the page. Creator+, and additionally conditioned on account age, reputation and review state — so a requested `true` can resolve to `false`." },
          "hideBadge": { "type": "boolean", "description": "Creator+. Hides the 302.sh badge. The abuse-report link is never removable, at any tier." },
          "unsafe": { "type": "boolean", "description": "True when moderation has taken the page down. It serves 410 and cannot be edited or deleted through the API." },
          "reviewState": { "type": ["string", "null"], "description": "`pending` when automated content screening flagged the page for a human look. The page still serves; it just cannot be indexed meanwhile." },
          "createdAt": { "type": "integer", "description": "Unix milliseconds." },
          "updatedAt": { "type": "integer", "description": "Unix milliseconds." }
        }
      },
      "CreateBioPageBody": {
        "type": "object",
        "required": ["handle"],
        "properties": {
          "handle": { "type": "string", "description": "Lowercased and trimmed server-side. Minimum length follows your tier, and reserved names are refused." },
          "displayName": { "type": ["string", "null"], "maxLength": 60 },
          "bio": { "type": ["string", "null"], "maxLength": 200 },
          "theme": { "type": "string", "enum": ["porcelain", "aurora", "mosaic", "graphite", "forest", "sandstone", "terminal", "brutal", "editorial"], "default": "porcelain" },
          "themeMode": { "type": "string", "enum": ["light", "dark", "system"], "default": "system" },
          "lang": { "type": "string", "enum": ["en", "es", "ja", "pt", "de", "fr", "ko"], "default": "en" },
          "blocks": { "type": "array", "items": { "$ref": "#/components/schemas/BioBlock" } },
          "socials": { "type": "array", "items": { "$ref": "#/components/schemas/BioSocial" } },
          "utm": { "type": ["object", "null"], "description": "Campaign parameters stamped onto every outbound destination at click time, so this page shows up as a source in your own analytics. Send `null` to clear them; omitting the field keeps what is stored. A destination that already carries its own `utm_` value keeps it, and `mailto:` targets are never touched. NOT a passthrough of the visitor’s own ad click id (`fbclid` / `gclid`): the rendered page is edge-cached and shared by every visitor, so the outbound hop structurally cannot carry a per-visitor value.", "properties": { "source": { "type": "string", "maxLength": 64, "pattern": "^[A-Za-z0-9._~-]+$" }, "medium": { "type": "string", "maxLength": 64, "pattern": "^[A-Za-z0-9._~-]+$" }, "campaign": { "type": "string", "maxLength": 64, "pattern": "^[A-Za-z0-9._~-]+$" } } },
          "published": { "type": "boolean", "default": false },
          "indexable": { "type": "boolean", "default": false },
          "hideBadge": { "type": "boolean", "default": false }
        }
      },
      "UpdateBioPageBody": {
        "type": "object",
        "description": "All fields optional; omitting one keeps its current value. Sending `handle` renames the page: the old handle 301s and is reserved for 90 days, then stops resolving. (The one-year 301 is a different thing — it is what attaching your own hostname leaves behind on the shared bio host.)",
        "properties": {
          "handle": { "type": "string" },
          "displayName": { "type": ["string", "null"] },
          "bio": { "type": ["string", "null"] },
          "theme": { "type": "string" },
          "themeMode": { "type": "string", "enum": ["light", "dark", "system"] },
          "lang": { "type": "string", "enum": ["en", "es", "ja", "pt", "de", "fr", "ko"] },
          "blocks": { "type": "array", "items": { "$ref": "#/components/schemas/BioBlock" } },
          "socials": { "type": "array", "items": { "$ref": "#/components/schemas/BioSocial" } },
          "utm": { "type": ["object", "null"], "description": "Campaign parameters stamped onto every outbound destination at click time, so this page shows up as a source in your own analytics. Send `null` to clear them; omitting the field keeps what is stored. A destination that already carries its own `utm_` value keeps it, and `mailto:` targets are never touched. NOT a passthrough of the visitor’s own ad click id (`fbclid` / `gclid`): the rendered page is edge-cached and shared by every visitor, so the outbound hop structurally cannot carry a per-visitor value.", "properties": { "source": { "type": "string", "maxLength": 64, "pattern": "^[A-Za-z0-9._~-]+$" }, "medium": { "type": "string", "maxLength": 64, "pattern": "^[A-Za-z0-9._~-]+$" }, "campaign": { "type": "string", "maxLength": 64, "pattern": "^[A-Za-z0-9._~-]+$" } } },
          "published": { "type": "boolean" },
          "indexable": { "type": "boolean" },
          "hideBadge": { "type": "boolean" }
        }
      },
      "BioBreakdown": {
        "type": "object",
        "description": "The same per-dimension breakdown object as AnalyticsSummary, minus `slug` and `range` (the request already fixes both).",
        "required": ["totalClicks", "uniqueVisitors", "uniqueVisitorsPartial", "botClicks", "timeseries"],
        "properties": {
          "totalClicks": { "type": "integer", "description": "Views for the `views` object, outbound presses for the `clicks` object." },
          "uniqueVisitors": { "type": "integer" },
          "uniqueVisitorsPartial": { "type": "boolean" },
          "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" } } } },
          "topLinks": { "type": "array", "description": "Always empty for a bio page — the field exists because the breakdown object is shared with the link surfaces.", "items": { "type": "object" } }
        }
      },
      "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." }
        }
      }
    }
  }
}
