{
  "openapi": "3.1.0",
  "info": {
    "title": "Blinkhop API",
    "version": "1.0.0",
    "summary": "Shorten links into zou.sh links, expand short links, report abuse.",
    "description": "A small REST API that does one thing well. No key is needed to start: anonymous calls are limited to 20 new links per minute and 300 per day per IP address. Rate-limited endpoints return X-RateLimit-* headers. CORS is open on api.blinkhop.com.",
    "contact": { "name": "Blinkhop", "url": "https://blinkhop.com/contact" },
    "termsOfService": "https://blinkhop.com/legal/terms"
  },
  "externalDocs": { "description": "API reference", "url": "https://blinkhop.com/docs/api" },
  "servers": [{ "url": "https://api.blinkhop.com/v1" }],
  "tags": [
    { "name": "Links", "description": "Create and look up short links." },
    { "name": "Tools", "description": "Expand short links and report abuse." },
    { "name": "Service", "description": "Status and health." }
  ],
  "paths": {
    "/links": {
      "post": {
        "tags": ["Links"],
        "operationId": "createLink",
        "summary": "Create a short link",
        "description": "Returns 201 with a new link, or 200 with the existing link when the same URL was already shortened without a custom ending. Add ?format=text to receive only the short URL as plain text.",
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "required": false,
            "description": "Set to text to receive only the short URL.",
            "schema": { "type": "string", "enum": ["json", "text"], "default": "json" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/CreateLink" } },
            "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/CreateLink" } }
          }
        },
        "responses": {
          "201": { "$ref": "#/components/responses/Link" },
          "200": { "$ref": "#/components/responses/Link" },
          "400": { "$ref": "#/components/responses/Error" },
          "409": { "$ref": "#/components/responses/Error" },
          "422": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/links/bulk": {
      "post": {
        "tags": ["Links"],
        "operationId": "createLinks",
        "summary": "Create up to 100 short links at once",
        "description": "Each URL counts against the rate limit. Invalid URLs return an error object in their slot instead of failing the whole request.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["urls"],
                "properties": {
                  "urls": { "type": "array", "minItems": 1, "maxItems": 100, "items": { "type": "string", "format": "uri" } }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "One result per URL, in the same order.",
            "headers": { "$ref": "#/components/headers/RateLimitAll" },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "links": { "type": "array", "items": { "$ref": "#/components/schemas/BulkItem" } } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "422": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/links/{code}": {
      "get": {
        "tags": ["Links"],
        "operationId": "getLink",
        "summary": "Look up a short link",
        "parameters": [
          { "name": "code", "in": "path", "required": true, "description": "The code after zou.sh/ (case-insensitive).", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "$ref": "#/components/responses/Link" },
          "404": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/expand": {
      "get": {
        "tags": ["Tools"],
        "operationId": "expandLink",
        "summary": "See where any short link goes",
        "description": "Follows up to 10 redirects without loading the destination page, and checks the final domain against our phishing and malware blocklist. Works with zou.sh and other shorteners. Limited to 30 lookups per minute per IP.",
        "parameters": [
          { "name": "url", "in": "query", "required": true, "description": "The short link to expand.", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Redirect chain and final destination.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Expand" } } }
          },
          "422": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/reports": {
      "post": {
        "tags": ["Tools"],
        "operationId": "reportLink",
        "summary": "Report a suspicious zou.sh link",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["short_url"],
                "properties": {
                  "short_url": { "type": "string", "description": "The zou.sh link or its code." },
                  "reason": { "type": "string", "maxLength": 1000 }
                }
              }
            }
          }
        },
        "responses": {
          "202": { "description": "Report received.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/status": {
      "get": {
        "tags": ["Service"],
        "operationId": "getStatus",
        "summary": "Live status and uptime",
        "responses": {
          "200": { "description": "Current status of each component and daily uptime for the last 90 days.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Status" } } } }
        }
      }
    },
    "/health": {
      "get": {
        "tags": ["Service"],
        "operationId": "getHealth",
        "summary": "Health check",
        "responses": {
          "200": { "description": "The API is up.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "version": { "type": "string" } } } } } }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "CreateLink": {
        "type": "object",
        "required": ["url"],
        "properties": {
          "url": { "type": "string", "description": "The long URL (http or https). A missing scheme defaults to https.", "examples": ["https://example.com/a/very/long/link"] },
          "alias": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$", "description": "Optional custom ending, unique regardless of letter case." }
        }
      },
      "Link": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "examples": ["lnk_7Kp2x"] },
          "code": { "type": "string", "examples": ["7Kp2x"] },
          "short_url": { "type": "string", "format": "uri", "examples": ["https://zou.sh/7Kp2x"] },
          "url": { "type": "string", "format": "uri" },
          "qr_url": { "type": "string", "format": "uri", "description": "SVG QR code of the short link." },
          "preview_url": { "type": "string", "format": "uri", "description": "Public preview page (destination and clicks)." },
          "clicks": { "type": "integer", "description": "Total human clicks (bots and link previews excluded)." },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "BulkItem": {
        "oneOf": [
          { "allOf": [{ "$ref": "#/components/schemas/Link" }, { "type": "object", "properties": { "created": { "type": "boolean" } } }] },
          { "type": "object", "properties": { "url": { "type": "string" }, "error": { "$ref": "#/components/schemas/ErrorBody" } } }
        ]
      },
      "Expand": {
        "type": "object",
        "properties": {
          "url": { "type": "string" },
          "final_url": { "type": "string" },
          "redirects": { "type": "integer" },
          "reached": { "type": "boolean", "description": "True when the final URL answered with a status below 400." },
          "flagged": { "type": "boolean", "description": "True when the final domain is on our phishing and malware blocklist." },
          "hops": {
            "type": "array",
            "items": { "type": "object", "properties": { "url": { "type": "string" }, "status": { "type": "integer" }, "error": { "type": "string", "enum": ["unreachable", "blocked_address", "unsupported_port"], "description": "Set when this hop could not be followed: no answer within 5 seconds, a private network address, or a non-standard port." } } }
          }
        }
      },
      "Status": {
        "type": "object",
        "properties": {
          "status": { "type": "string", "enum": ["operational", "degraded"] },
          "checked_at": { "type": "string", "format": "date-time" },
          "monitoring_since": { "type": "string", "format": "date-time" },
          "uptime_90d": { "type": ["number", "null"] },
          "components": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "status": { "type": "string" } } } },
          "days": { "type": "array", "items": { "type": "object", "properties": { "day": { "type": "string", "format": "date" }, "uptime": { "type": ["number", "null"] } } } }
        }
      },
      "ErrorBody": {
        "type": "object",
        "properties": {
          "code": { "type": "string", "enum": ["missing_url", "invalid_url", "already_short", "url_too_long", "unsafe_destination", "invalid_alias", "alias_taken", "invalid_code", "rate_limited", "not_found", "invalid_json", "too_large", "missing_urls", "too_many_urls", "internal"] },
          "message": { "type": "string" },
          "field": { "type": "string" }
        }
      },
      "Error": { "type": "object", "properties": { "error": { "$ref": "#/components/schemas/ErrorBody" } } }
    },
    "headers": {
      "RateLimitAll": {
        "description": "X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix time) are sent with this response.",
        "schema": { "type": "integer" }
      },
      "X-RateLimit-Limit": { "description": "Links allowed per minute.", "schema": { "type": "integer" } },
      "X-RateLimit-Remaining": { "description": "Links left in the current minute.", "schema": { "type": "integer" } },
      "X-RateLimit-Reset": { "description": "When the minute window resets (Unix time, seconds).", "schema": { "type": "integer" } },
      "Retry-After": { "description": "Seconds to wait before retrying.", "schema": { "type": "integer" } }
    },
    "responses": {
      "Link": {
        "description": "The short link.",
        "headers": {
          "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" },
          "X-RateLimit-Remaining": { "$ref": "#/components/headers/X-RateLimit-Remaining" },
          "X-RateLimit-Reset": { "$ref": "#/components/headers/X-RateLimit-Reset" }
        },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Link" } },
          "text/plain": { "schema": { "type": "string", "examples": ["https://zou.sh/7Kp2x"] } }
        }
      },
      "Error": {
        "description": "Error with a stable code and a human-readable message.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Rate limit reached.",
        "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  }
}
