{
  "openapi": "3.0.3",
  "info": {
    "title": "URLBOT REST API",
    "version": "1.0.0",
    "x-build": "urlbot-stage40-openapi-20260807",
    "description": "REST API for the URLBOT link shortener. Every endpoint is authenticated with an API key and scoped to the owning account. API keys are issued to Business-plan accounts and above from the dashboard. This document is served at /docs/openapi.json and /docs/openapi.yaml and linked from the human-readable reference at /docs/api.",
    "contact": {
      "name": "URLBOT support",
      "email": "support@urlbot.cc"
    }
  },
  "servers": [
    {
      "url": "https://urlbot.cc/api/v1",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Account",
      "description": "Account and plan introspection."
    },
    {
      "name": "Links",
      "description": "Create, read, update and delete short links."
    }
  ],
  "paths": {
    "/me": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "Get the authenticated account",
        "operationId": "getMe",
        "responses": {
          "200": {
            "description": "The account that owns the API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Me"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/plans": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "List every plan's capabilities",
        "description": "Reports the capability set of every plan alongside the caller's own plan, so an integration can discover what it may call instead of discovering it by collecting 403s.",
        "operationId": "listPlans",
        "responses": {
          "200": {
            "description": "Capability report for all plans.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlansResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/links": {
      "get": {
        "tags": [
          "Links"
        ],
        "summary": "List the caller's links",
        "description": "Supports cursor paging (recommended) and offset paging. Pass the next_cursor value from the previous page verbatim as the cursor query parameter. Results are ordered created_at DESC, id DESC.",
        "operationId": "listLinks",
        "parameters": [
          {
            "$ref": "#/components/parameters/limitParam"
          },
          {
            "$ref": "#/components/parameters/offsetParam"
          },
          {
            "$ref": "#/components/parameters/cursorParam"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of links.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LinksResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "tags": [
          "Links"
        ],
        "summary": "Create a link",
        "operationId": "createLink",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateLinkRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created link.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LinkResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Plan quota exceeded (error: quota_exceeded).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Requested alias already in use (error: alias_taken).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/links/bulk": {
      "post": {
        "tags": [
          "Links"
        ],
        "summary": "Create up to 100 links in one call",
        "description": "Items are processed independently: one bad URL does not discard the rest, and the response reports every outcome by index. Returns 201 when at least one link was created, or 422 when none were.",
        "operationId": "bulkCreateLinks",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BulkCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "At least one link was created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkCreateResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "description": "No links could be created; see per-item results.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkCreateResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/links/{code}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/codeParam"
        }
      ],
      "get": {
        "tags": [
          "Links"
        ],
        "summary": "Get one link",
        "operationId": "getLink",
        "responses": {
          "200": {
            "description": "The requested link.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LinkResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "Links"
        ],
        "summary": "Update a link",
        "description": "Only the supplied fields change. A present-but-empty tags string clears the labels; an omitted tags field leaves them untouched.",
        "operationId": "updateLink",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateLinkRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated link.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LinkResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "Links"
        ],
        "summary": "Delete a link",
        "operationId": "deleteLink",
        "responses": {
          "200": {
            "description": "The link was deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeleteResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/links/{code}/stats": {
      "parameters": [
        {
          "$ref": "#/components/parameters/codeParam"
        }
      ],
      "get": {
        "tags": [
          "Links"
        ],
        "summary": "Get click statistics for a link",
        "description": "Totals honour the account's plan-based analytics retention window; lifetime_clicks is the all-time counter and is never truncated.",
        "operationId": "getLinkStats",
        "responses": {
          "200": {
            "description": "Click statistics for the link.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StatsResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Send the API key as: Authorization: Bearer <key>"
      },
      "apiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Send the API key as: X-API-Key: <key>"
      }
    },
    "parameters": {
      "codeParam": {
        "name": "code",
        "in": "path",
        "required": true,
        "description": "The short code of the link.",
        "schema": {
          "type": "string"
        }
      },
      "limitParam": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Maximum links to return. Defaults to 50; values outside 1-200 fall back to 50.",
        "schema": {
          "type": "integer",
          "default": 50,
          "minimum": 1,
          "maximum": 200
        }
      },
      "offsetParam": {
        "name": "offset",
        "in": "query",
        "required": false,
        "description": "Offset paging cursor. Ignored when the cursor parameter is supplied.",
        "schema": {
          "type": "integer",
          "default": 0,
          "minimum": 0
        }
      },
      "cursorParam": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "Opaque keyset cursor from a previous page's next_cursor. Preferred over offset.",
        "schema": {
          "type": "string"
        }
      }
    },
    "schemas": {
      "Link": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64",
            "example": 1042
          },
          "short_code": {
            "type": "string",
            "example": "launch"
          },
          "short_url": {
            "type": "string",
            "format": "uri",
            "example": "https://urlbot.cc/launch"
          },
          "original_url": {
            "type": "string",
            "format": "uri",
            "example": "https://example.com/landing"
          },
          "is_active": {
            "type": "boolean"
          },
          "status": {
            "type": "string",
            "example": "active"
          },
          "available": {
            "type": "boolean",
            "description": "Whether the link currently redirects."
          },
          "has_password": {
            "type": "boolean"
          },
          "click_count": {
            "type": "integer",
            "format": "int64"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Omitted when the link never expires."
          },
          "domain": {
            "type": "string",
            "description": "Custom domain hostname; omitted on the default domain."
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Always present; empty when the link has no tags."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "short_code",
          "short_url",
          "original_url",
          "is_active",
          "status",
          "available",
          "has_password",
          "click_count",
          "tags",
          "created_at"
        ]
      },
      "Quota": {
        "type": "object",
        "properties": {
          "limit": {
            "type": "integer",
            "description": "Link allowance; negative means unlimited."
          },
          "used": {
            "type": "integer",
            "format": "int64"
          },
          "unlimited": {
            "type": "boolean"
          },
          "period": {
            "type": "string",
            "example": "month"
          }
        },
        "required": [
          "limit",
          "used",
          "unlimited",
          "period"
        ]
      },
      "Me": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "username": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "plan": {
            "type": "string",
            "example": "business"
          },
          "quota": {
            "$ref": "#/components/schemas/Quota"
          },
          "analytics_retention_days": {
            "type": "integer",
            "description": "0 means unlimited retention."
          }
        },
        "required": [
          "id",
          "username",
          "email",
          "plan",
          "quota",
          "analytics_retention_days"
        ]
      },
      "PlanFeature": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "included": {
            "type": "boolean"
          },
          "minimum_plan": {
            "type": "string"
          }
        },
        "required": [
          "key",
          "label",
          "included",
          "minimum_plan"
        ]
      },
      "Plan": {
        "type": "object",
        "properties": {
          "plan": {
            "type": "string",
            "example": "business"
          },
          "current": {
            "type": "boolean"
          },
          "custom_domains": {
            "type": "integer"
          },
          "team_members": {
            "type": "integer"
          },
          "features": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlanFeature"
            }
          }
        },
        "required": [
          "plan",
          "current",
          "custom_domains",
          "team_members",
          "features"
        ]
      },
      "PlansResponse": {
        "type": "object",
        "properties": {
          "current_plan": {
            "type": "string"
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Plan"
            }
          }
        },
        "required": [
          "current_plan",
          "data"
        ]
      },
      "Pagination": {
        "type": "object",
        "properties": {
          "total": {
            "type": "integer",
            "format": "int64"
          },
          "limit": {
            "type": "integer"
          },
          "count": {
            "type": "integer"
          },
          "has_more": {
            "type": "boolean"
          },
          "next_cursor": {
            "type": "string",
            "description": "Empty when there is no further page."
          },
          "offset": {
            "type": "integer",
            "description": "Present only for offset paging (omitted when a cursor was used)."
          }
        },
        "required": [
          "total",
          "limit",
          "count",
          "has_more",
          "next_cursor"
        ]
      },
      "LinksResponse": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Link"
            }
          },
          "pagination": {
            "$ref": "#/components/schemas/Pagination"
          }
        },
        "required": [
          "data",
          "pagination"
        ]
      },
      "LinkResponse": {
        "type": "object",
        "properties": {
          "data": {
            "$ref": "#/components/schemas/Link"
          }
        },
        "required": [
          "data"
        ]
      },
      "DeleteResponse": {
        "type": "object",
        "properties": {
          "deleted": {
            "type": "boolean"
          },
          "short_code": {
            "type": "string"
          }
        },
        "required": [
          "deleted",
          "short_code"
        ]
      },
      "CreateLinkRequest": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Destination URL. Required.",
            "example": "https://example.com/landing"
          },
          "alias": {
            "type": "string",
            "description": "Custom short code (letters, numbers, hyphens, underscores, max 64). Auto-generated when omitted."
          },
          "password": {
            "type": "string",
            "description": "Optional access password (max 128 chars)."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Optional RFC 3339 expiry, e.g. 2026-12-31T23:59:00Z. Must be in the future."
          },
          "domain_id": {
            "type": "string",
            "description": "Optional custom-domain id to shorten under."
          },
          "tags": {
            "type": "string",
            "description": "Comma-separated tags; lower-cased, de-duplicated, max 8.",
            "example": "launch,q3"
          }
        },
        "required": [
          "url"
        ]
      },
      "UpdateLinkRequest": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "New destination URL."
          },
          "is_active": {
            "type": "boolean",
            "description": "Enable or disable the link."
          },
          "tags": {
            "type": "string",
            "description": "Comma-separated tags. Empty string clears all tags; omit to leave unchanged."
          }
        }
      },
      "BulkCreateRequest": {
        "type": "object",
        "properties": {
          "links": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/CreateLinkRequest"
            }
          }
        },
        "required": [
          "links"
        ]
      },
      "BulkResult": {
        "type": "object",
        "properties": {
          "index": {
            "type": "integer",
            "description": "Zero-based position in the submitted links array."
          },
          "created": {
            "type": "boolean"
          },
          "error": {
            "type": "string",
            "description": "Error code; present only on failure."
          },
          "message": {
            "type": "string",
            "description": "Human-readable message; present only on failure."
          },
          "data": {
            "$ref": "#/components/schemas/Link"
          }
        },
        "required": [
          "index",
          "created"
        ]
      },
      "BulkCreateResponse": {
        "type": "object",
        "properties": {
          "created": {
            "type": "integer"
          },
          "failed": {
            "type": "integer"
          },
          "submitted": {
            "type": "integer"
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BulkResult"
            }
          }
        },
        "required": [
          "created",
          "failed",
          "submitted",
          "results"
        ]
      },
      "CountryStat": {
        "type": "object",
        "properties": {
          "country": {
            "type": "string"
          },
          "clicks": {
            "type": "integer",
            "format": "int64"
          }
        },
        "required": [
          "country",
          "clicks"
        ]
      },
      "StatsTotals": {
        "type": "object",
        "properties": {
          "clicks": {
            "type": "integer",
            "format": "int64"
          },
          "human": {
            "type": "integer",
            "format": "int64"
          },
          "bot": {
            "type": "integer",
            "format": "int64"
          },
          "unique_visitors": {
            "type": "integer",
            "format": "int64"
          },
          "lifetime_clicks": {
            "type": "integer",
            "format": "int64",
            "description": "All-time counter, never truncated by retention."
          }
        },
        "required": [
          "clicks",
          "human",
          "bot",
          "unique_visitors",
          "lifetime_clicks"
        ]
      },
      "StatsResponse": {
        "type": "object",
        "properties": {
          "short_code": {
            "type": "string"
          },
          "totals": {
            "$ref": "#/components/schemas/StatsTotals"
          },
          "top_countries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CountryStat"
            }
          },
          "retention_days": {
            "type": "integer",
            "description": "0 means unlimited retention."
          }
        },
        "required": [
          "short_code",
          "totals",
          "top_countries",
          "retention_days"
        ]
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string",
            "description": "Machine-readable error code.",
            "example": "invalid_url"
          },
          "message": {
            "type": "string",
            "description": "Human-readable detail (present on some errors)."
          },
          "hint": {
            "type": "string",
            "description": "Actionable hint (present on some errors)."
          }
        },
        "required": [
          "error"
        ],
        "additionalProperties": true,
        "description": "Standard error envelope. Known codes include: invalid_body, invalid_url, invalid_alias, reserved_alias, invalid_expires_at, expires_at_in_past, password_too_long, invalid_cursor, empty_batch, batch_too_large (400); missing_api_key, invalid_api_key, account_not_found (401); quota_exceeded (403); link_not_found (404); alias_taken (409); rate_limited (429)."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request was malformed or failed validation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "The API key is missing or invalid.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "No link with that code is owned by the caller (error: link_not_found).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "The per-key request budget is exhausted (error: rate_limited). Default 120 requests / 60 s.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds to wait before retrying."
          },
          "X-RateLimit-Limit": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Remaining": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Reset": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds until the window resets."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}
