{
  "openapi": "3.1.0",
  "info": {
    "title": "Gozi.to API",
    "version": "1.0.0",
    "summary": "Create and manage gozi.to short links and read their click statistics.",
    "description": "Free REST API for the Gozi.to URL shortener. Authenticate with a bearer API key created in the dashboard. All timestamps are epoch milliseconds. Human-readable documentation: https://gozi.to/docs\n\n## Rate limits\n\n- 60 requests per minute per API key (and per browser session).\n- 500 new links per account per rolling 24 hours.\n- Anonymous creation: 5 per minute and 25 per day per IP address, and a Cloudflare Turnstile token is required, so it cannot be scripted.\n\nExceeding any of these returns 429 with error.code `rate_limited` or `daily_limit`.\n\n## Availability\n\nGozi.to runs on Cloudflare's free plan. When the operator's daily request budget reaches 90%, anonymous link creation returns 503 with error.code `paused` until 00:00 UTC; authenticated creation is unaffected.\n\n## Errors\n\nEvery error body is `{ \"error\": { \"code\", \"message\", \"key\"?, \"params\"? } }`. `code` is the stable contract; `key` and `params` are hints the website uses to show a translated message and may be ignored.",
    "termsOfService": "https://gozi.to/terms",
    "contact": {
      "name": "Gozi.to",
      "email": "hello@gozi.to",
      "url": "https://gozi.to/docs"
    }
  },
  "servers": [
    {
      "url": "https://gozi.to",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "url": "https://gozi.to/docs",
    "description": "API documentation"
  },
  "security": [
    {
      "apiKey": []
    }
  ],
  "tags": [
    {
      "name": "Links",
      "description": "Create and manage links in your account."
    },
    {
      "name": "Anonymous",
      "description": "Links created without an account."
    },
    {
      "name": "Analytics",
      "description": "Click statistics."
    },
    {
      "name": "Abuse",
      "description": "Reporting harmful links."
    },
    {
      "name": "Meta",
      "description": "Account and service status."
    }
  ],
  "paths": {
    "/api/health": {
      "get": {
        "tags": [
          "Meta"
        ],
        "operationId": "health",
        "summary": "Service health",
        "description": "Returns `{\"ok\": true}` if the Worker is running. This endpoint costs one request from a shared daily Worker budget; do not poll it more than once every few minutes.",
        "security": [],
        "responses": {
          "200": {
            "description": "Healthy",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/me": {
      "get": {
        "tags": [
          "Meta"
        ],
        "operationId": "getMe",
        "summary": "The authenticated account",
        "description": "Returns the account behind the API key or session cookie, or `{\"user\": null}` when unauthenticated. Never returns 401.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "user"
                  ],
                  "properties": {
                    "user": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/User"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/links": {
      "post": {
        "tags": [
          "Links",
          "Anonymous"
        ],
        "operationId": "createLink",
        "summary": "Create a short link",
        "description": "Works two ways.\n\n**Authenticated** (`Authorization: Bearer gz_…`): `turnstileToken` is ignored, `code` may be supplied, and the link never expires. Prefer `POST /api/my/links`, which is identical but explicit.\n\n**Anonymous** (no Authorization header): `turnstileToken` is mandatory and must come from a Cloudflare Turnstile widget in a real browser, `code` is rejected with `custom_requires_account`, and the link expires after 90 days. The response then carries `manageUrl`, the only way to see stats or delete the link — returned exactly once.",
        "security": [
          {
            "apiKey": []
          },
          {}
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "$ref": "#/components/schemas/Destination"
                  },
                  "code": {
                    "$ref": "#/components/schemas/CustomCode"
                  },
                  "turnstileToken": {
                    "type": "string",
                    "description": "Cloudflare Turnstile token. Required when unauthenticated."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "link"
                  ],
                  "properties": {
                    "link": {
                      "$ref": "#/components/schemas/Link"
                    },
                    "manageUrl": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "uri",
                      "description": "Anonymous links only. `https://gozi.to/m#m_<secret>` — the secret is in the URL fragment and is never sent to the server. Shown once, unrecoverable."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/CaptchaFailed"
          },
          "409": {
            "$ref": "#/components/responses/SlugTaken"
          },
          "422": {
            "$ref": "#/components/responses/DestinationBlocked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          }
        }
      }
    },
    "/api/manage": {
      "get": {
        "tags": [
          "Anonymous"
        ],
        "operationId": "getAnonLink",
        "summary": "Read an anonymous link and its statistics",
        "description": "Authenticate with the management token: `Authorization: Bearer m_…`.",
        "security": [
          {
            "manageToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "link",
                    "stats"
                  ],
                  "properties": {
                    "link": {
                      "$ref": "#/components/schemas/Link"
                    },
                    "stats": {
                      "$ref": "#/components/schemas/Stats"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "Anonymous"
        ],
        "operationId": "deleteAnonLink",
        "summary": "Delete an anonymous link",
        "description": "There is no way to change an anonymous link's destination — only read it or delete it.",
        "security": [
          {
            "manageToken": []
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/reports": {
      "post": {
        "tags": [
          "Abuse"
        ],
        "operationId": "reportLink",
        "summary": "Report a harmful link",
        "description": "Anonymous, and requires a Cloudflare Turnstile token. 3 distinct reporters put a link behind a warning page immediately.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "link",
                  "reason",
                  "turnstileToken"
                ],
                "properties": {
                  "link": {
                    "type": "string",
                    "description": "The gozi.to short link or its code."
                  },
                  "reason": {
                    "type": "string",
                    "enum": [
                      "phishing",
                      "malware",
                      "scam",
                      "spam",
                      "illegal",
                      "other"
                    ]
                  },
                  "details": {
                    "type": "string",
                    "maxLength": 2000
                  },
                  "turnstileToken": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/CaptchaFailed"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/my/links": {
      "get": {
        "tags": [
          "Links"
        ],
        "operationId": "listLinks",
        "summary": "List your links",
        "description": "Newest first, cursor-paginated. Pass the previous response's `nextCursor`; it is null on the last page.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "integer"
            },
            "description": "`nextCursor` from the previous page."
          },
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Substring match on the short code or the destination URL."
          },
          {
            "name": "clicks",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Set to 1 to include lifetime human click counts. Costs one Analytics Engine query; cached about 5 minutes."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "links",
                    "nextCursor"
                  ],
                  "properties": {
                    "links": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Link"
                      }
                    },
                    "nextCursor": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "tags": [
          "Links"
        ],
        "operationId": "createOwnedLink",
        "summary": "Create a link in your account",
        "description": "One link per request; there is no bulk endpoint. Counts against the 500-per-day account cap.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "$ref": "#/components/schemas/Destination"
                  },
                  "code": {
                    "$ref": "#/components/schemas/CustomCode"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "$ref": "#/components/responses/LinkEnvelope"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/SlugTaken"
          },
          "422": {
            "$ref": "#/components/responses/DestinationBlocked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/my/links/{code}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Code"
        }
      ],
      "get": {
        "tags": [
          "Links"
        ],
        "operationId": "getLink",
        "summary": "Read one link",
        "responses": {
          "200": {
            "$ref": "#/components/responses/LinkEnvelope"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "Links"
        ],
        "operationId": "updateLink",
        "summary": "Change a link's destination and/or status",
        "description": "The short code itself can never change. Changing `url` re-runs every safety check, so a destination that is now blocked returns 422. `status` accepts only `active` and `disabled`; a link restricted by moderation returns 403 `status_locked` and can only be reopened by emailing hello@gozi.to.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "url": {
                    "$ref": "#/components/schemas/Destination"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "disabled"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/LinkEnvelope"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/StatusLocked"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/DestinationBlocked"
          }
        }
      },
      "delete": {
        "tags": [
          "Links"
        ],
        "operationId": "deleteLink",
        "summary": "Delete a link",
        "description": "The code is never reused.",
        "responses": {
          "200": {
            "$ref": "#/components/responses/Ok"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/my/links/{code}/stats": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Code"
        }
      ],
      "get": {
        "tags": [
          "Analytics"
        ],
        "operationId": "getLinkStats",
        "summary": "Click statistics",
        "description": "Human clicks for the last 90 days and for the link's lifetime, a per-day series, and the top countries, referring domains, devices, browsers and operating systems.\n\n**These are sampled estimates, not exact counts.** They come from Cloudflare Workers Analytics Engine, which samples under load and reports the scaled-up figure: close at volume, rough at low volume. Cached about 5 minutes. If the analytics backend is unavailable the response has `available: false` and zeroed counters rather than an error.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Stats"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/my/claim": {
      "post": {
        "tags": [
          "Links"
        ],
        "operationId": "claimLink",
        "summary": "Move an anonymous link into your account",
        "description": "Takes the `m_…` management token. The link stops expiring.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/LinkEnvelope"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/my/export.csv": {
      "get": {
        "tags": [
          "Links"
        ],
        "operationId": "exportCsv",
        "summary": "Export your links as CSV",
        "description": "At most the 10,000 most recent links.",
        "responses": {
          "200": {
            "description": "CSV",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "gz_<prefix>_<secret>",
        "description": "An API key from the dashboard. Shown once and stored only as a SHA-256 hash. Up to 10 active keys. A key cannot create or revoke other keys — that needs a browser session."
      },
      "manageToken": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "m_<secret>",
        "description": "The one-time management token for an anonymous link."
      }
    },
    "parameters": {
      "Code": {
        "name": "code",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{3,32}$"
        },
        "description": "The short code, without the leading slash."
      }
    },
    "schemas": {
      "Destination": {
        "type": "string",
        "format": "uri",
        "maxLength": 2048,
        "description": "http or https only. Raw IP addresses, credentials in the URL, private or unreachable hostnames, this shortener itself, and other URL shorteners are refused. Checked against Google Safe Browsing."
      },
      "CustomCode": {
        "type": "string",
        "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{3,31}$",
        "description": "Optional custom short code, 4-32 characters. Signed-in callers only. Reserved words and words that make a link look like a sign-in page are refused."
      },
      "Link": {
        "type": "object",
        "required": [
          "code",
          "shortUrl",
          "url",
          "status",
          "custom",
          "createdAt"
        ],
        "properties": {
          "code": {
            "type": "string"
          },
          "shortUrl": {
            "type": "string",
            "format": "uri"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "preview",
              "disabled",
              "deleted"
            ],
            "description": "`preview` means the link shows a warning page before redirecting."
          },
          "statusReason": {
            "type": [
              "string",
              "null"
            ]
          },
          "custom": {
            "type": "boolean"
          },
          "createdAt": {
            "type": "integer",
            "description": "Epoch milliseconds."
          },
          "expiresAt": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Null for links owned by an account: those never expire."
          },
          "clicks": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Only present on list responses requested with clicks=1."
          }
        }
      },
      "Bucket": {
        "type": "object",
        "required": [
          "key",
          "clicks"
        ],
        "properties": {
          "key": {
            "type": "string"
          },
          "clicks": {
            "type": "integer"
          }
        }
      },
      "Stats": {
        "type": "object",
        "required": [
          "available"
        ],
        "properties": {
          "available": {
            "type": "boolean",
            "description": "False when the analytics backend could not be reached; every counter is then zero."
          },
          "clicks": {
            "type": "integer",
            "description": "Human clicks in the window. A sampled estimate."
          },
          "lifetimeClicks": {
            "type": "integer"
          },
          "botClicks": {
            "type": "integer",
            "description": "Bots and link-preview fetchers, excluded from `clicks`."
          },
          "windowDays": {
            "type": "integer"
          },
          "timeseries": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "day": {
                  "type": "string",
                  "format": "date"
                },
                "clicks": {
                  "type": "integer"
                }
              }
            }
          },
          "countries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Bucket"
            }
          },
          "referrers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Bucket"
            },
            "description": "Referring domain only, never the full URL. `(direct)` when there was none."
          },
          "devices": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Bucket"
            }
          },
          "browsers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Bucket"
            }
          },
          "os": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Bucket"
            }
          }
        }
      },
      "User": {
        "type": "object",
        "required": [
          "id",
          "email",
          "role"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "role": {
            "type": "string",
            "enum": [
              "user",
              "admin"
            ]
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string"
              },
              "message": {
                "type": "string"
              },
              "key": {
                "type": "string"
              },
              "params": {
                "type": "object"
              }
            }
          }
        }
      }
    },
    "responses": {
      "Ok": {
        "description": "Success",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "ok"
              ],
              "properties": {
                "ok": {
                  "const": true
                }
              }
            }
          }
        }
      },
      "LinkEnvelope": {
        "description": "OK",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "link"
              ],
              "properties": {
                "link": {
                  "$ref": "#/components/schemas/Link"
                }
              }
            }
          }
        }
      },
      "BadRequest": {
        "description": "`bad_request`, `invalid_url`, `invalid_slug`, `invalid_status`, `invalid_token`, `invalid_link`, `invalid_reason`, `custom_requires_account`",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "`unauthorized` or `invalid_api_key`",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "CaptchaFailed": {
        "description": "`captcha_failed` — the Turnstile token was missing, expired or rejected.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "StatusLocked": {
        "description": "`status_locked` — restricted by moderation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "`not_found`",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "SlugTaken": {
        "description": "`slug_taken`",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "DestinationBlocked": {
        "description": "`destination_blocked` — a blocklisted domain, or flagged by Google Safe Browsing.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "`rate_limited` (per minute) or `daily_limit` (25/day anonymous, 500/day account)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Paused": {
        "description": "`paused` — anonymous creation is suspended until 00:00 UTC because the free-plan request or storage budget is nearly used up. Authenticated creation still works.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}
