{
  "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**Scopes:** a token reads everything the account owns by default. Routes that publish hosted content — the `/api/bio` write routes — additionally require the `bio:write` scope, chosen when the token is created. 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; redirects always continue working.\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. Write routes require the `bio:write` token scope; reads do not. `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/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\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": "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/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", "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"] } }
      ],
      "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 the `bio:write` scope. 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 the `bio:write` scope. 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 the `bio:write` scope. 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 the `bio:write` scope. 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": "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. 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": "tz", "in": "query", "schema": { "type": "string", "default": "UTC" } }
      ],
      "get": {
        "tags": ["bio", "analytics"],
        "summary": "Download bio-page analytics as CSV",
        "description": "No scope required. 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`, `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": {
      "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": {
          "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. Creator+ 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": "Creator+ only. The legacy stable error code for a Free account is cloak_requires_pro." }
        }
      },
      "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": "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": {
          "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", "zh", "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", "zh", "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", "zh", "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." }
        }
      }
    }
  }
}
