{
  "openapi": "3.0.3",
  "info": {
    "title": "99L URL Shortener API",
    "version": "1.1.0",
    "description": "Shorten links, claim custom aliases, and create links in bulk.\n\n**No key needed** for `/api/shorten.php`. The bulk endpoint requires an API key, email hello@99l.in.\n\n### Rate limits\n| Endpoint | Limit |\n|---|---|\n| `/api/shorten.php` (random code) | 15/min, 120/hour per IP |\n| `/api/shorten.php` (custom alias) | 5/min, 25/hour, 60/day per IP |\n| `/api/bulk.php` | 100 links/request, daily quota per key |\n\nExceeding a limit returns `429` with a `Retry-After` header.",
    "contact": { "name": "99L support", "email": "hello@99l.in" }
  },
  "servers": [{ "url": "https://99l.in", "description": "Production" }],
  "tags": [
    { "name": "Links", "description": "Create short links" },
    { "name": "Enterprise", "description": "Bulk access" }
  ],
  "paths": {
    "/api/shorten.php": {
      "post": {
        "tags": ["Links"],
        "summary": "Shorten one URL",
        "description": "Accepts form-encoded or JSON input. Supply `alias` to claim a custom name.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ShortenRequest" },
              "examples": {
                "random": { "summary": "Random code", "value": { "url": "https://example.com/a/very/long/page" } },
                "alias":  { "summary": "Custom alias", "value": { "url": "https://example.com/spring-sale", "alias": "spring-sale" } }
              }
            },
            "application/x-www-form-urlencoded": {
              "schema": { "$ref": "#/components/schemas/ShortenRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Link created",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShortenResponse" },
              "example": { "ok": true, "short": "https://99l.in/spring-sale", "code": "spring-sale", "long": "https://example.com/spring-sale", "custom": true } } }
          },
          "400": { "description": "Invalid URL or alias", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "ok": false, "error": "That does not look like a valid web address." } } } },
          "405": { "description": "Wrong method", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "Alias already taken", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "ok": false, "error": "That alias is already taken. Try another." } } } },
          "429": { "description": "Rate limited", "headers": { "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds to wait" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "503": { "description": "Database unavailable", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/bulk.php": {
      "post": {
        "tags": ["Enterprise"],
        "summary": "Shorten up to 100 URLs in one call",
        "description": "Requires an API key. Each item succeeds or fails independently, so one bad row does not discard the rest of the batch.",
        "security": [{ "bearerAuth": [] }, { "apiKeyHeader": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkRequest" },
            "example": { "links": [
              { "url": "https://example.com/one", "alias": "promo-one" },
              { "url": "https://example.com/two" }
            ] } } }
        },
        "responses": {
          "200": { "description": "Batch processed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkResponse" },
            "example": { "ok": true, "requested": 2, "created": 2, "failed": 0, "remaining": 1998, "results": [
              { "index": 0, "ok": true, "short": "https://99l.in/promo-one", "code": "promo-one", "long": "https://example.com/one", "custom": true },
              { "index": 1, "ok": true, "short": "https://99l.in/gx", "code": "gx", "long": "https://example.com/two", "custom": false }
            ] } } } },
          "400": { "description": "Malformed body", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "Missing or invalid API key", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "Key disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "413": { "description": "More than 100 links", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "description": "Daily quota reached", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/{code}": {
      "get": {
        "tags": ["Links"],
        "summary": "Follow a short link",
        "description": "Returns a permanent redirect to the original URL and increments that link's click counter.",
        "parameters": [{ "name": "code", "in": "path", "required": true, "schema": { "type": "string" }, "example": "spring-sale" }],
        "responses": {
          "301": { "description": "Redirect to the long URL", "headers": { "Location": { "schema": { "type": "string" } } } },
          "302": { "description": "Unknown code - bounced to the site with an error" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": { "type": "http", "scheme": "bearer", "description": "Authorization: Bearer <your key>" },
      "apiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-Key" }
    },
    "schemas": {
      "ShortenRequest": {
        "type": "object",
        "required": ["url"],
        "properties": {
          "url": { "type": "string", "maxLength": 2000, "description": "The link to shorten. `https://` is assumed when no scheme is given.", "example": "https://example.com/page" },
          "alias": { "type": "string", "minLength": 3, "maxLength": 40, "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*[A-Za-z0-9]$", "description": "Optional custom name. Stored lowercase. Letters, numbers, hyphen and underscore.", "example": "spring-sale" }
        }
      },
      "ShortenResponse": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean" },
          "short": { "type": "string", "description": "The finished short link" },
          "code": { "type": "string", "description": "Just the code portion" },
          "long": { "type": "string", "description": "The normalised destination" },
          "custom": { "type": "boolean", "description": "True when a custom alias was used" }
        }
      },
      "BulkRequest": {
        "type": "object",
        "required": ["links"],
        "properties": {
          "links": {
            "type": "array", "minItems": 1, "maxItems": 100,
            "items": { "$ref": "#/components/schemas/ShortenRequest" }
          }
        }
      },
      "BulkResponse": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean" },
          "requested": { "type": "integer" },
          "created": { "type": "integer" },
          "failed": { "type": "integer" },
          "remaining": { "type": "integer", "description": "Links left in today's quota" },
          "results": { "type": "array", "items": { "type": "object", "properties": {
            "index": { "type": "integer" }, "ok": { "type": "boolean" },
            "short": { "type": "string" }, "code": { "type": "string" },
            "long": { "type": "string" }, "custom": { "type": "boolean" },
            "error": { "type": "string" } } } }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean", "example": false },
          "error": { "type": "string" },
          "retry_after": { "type": "integer", "description": "Present on 429 responses" }
        }
      }
    }
  }
}
