{
  "openapi": "3.1.0",
  "info": {
    "title": "GST Cranes Public API",
    "version": "1.0.0",
    "summary": "Public API for browsing used crane inventory and submitting inquiries.",
    "description": "The GST Cranes public API exposes the used mobile and crawler crane catalog, specifications, pricing, and endpoints to submit buyer inquiries and sell requests.\n\n## Authentication\n\nAll endpoints documented here are **public** and require no authentication. You do **not** need an API key, OAuth token, or HTTP Basic credentials to read inventory, submit an inquiry, or subscribe to the newsletter.\n\nFor completeness, the site publishes OAuth 2.0 / OpenID Connect discovery documents for the identity provider used by the admin area (Hercules Auth). These are **not** required for any endpoint in this specification.\n\n- OIDC discovery: [`/.well-known/openid-configuration`](/.well-known/openid-configuration)\n- Protected resource metadata (RFC 9728): [`/.well-known/oauth-protected-resource`](/.well-known/oauth-protected-resource)\n\nIf we introduce authenticated endpoints in the future, they will use the `OIDC` security scheme defined below (Bearer JWT in the `Authorization` header).\n\n## CAPTCHA & anti-spam policy (agent-friendly)\n\nWe do **not** use CAPTCHA on any endpoint in this specification — neither read nor write. All read endpoints (`GET /api/v1/cranes`, `GET /api/v1/cranes/{id}`, every `/.well-known/*` document, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml`) are freely crawlable and scriptable by AI agents.\n\nFor write endpoints (`POST /api/v1/inquiries`, `POST /api/v1/crane-requests`, `POST /api/v1/newsletter/subscribe`) we rely on:\n\n1. **Rate limiting** (10 req/min per IP, 60 s rolling window) — documented above.\n2. **Honeypot fields** — each write payload accepts an optional `website` (and for inquiries, `botField`) property. These fields must be left **empty or omitted**. Requests that submit a non-empty value are silently accepted (HTTP `201`) but discarded. Legitimate AI agents should simply not include these fields.\n3. **Server-side validation** of required fields, enums, and email format — documented per endpoint.\n\nShould we ever add CAPTCHA in the future, this spec guarantees two agent-friendly bypasses that will be documented here before rollout:\n\n- **API key header** — `X-API-Key: <key>` issued on request via `/en/contact`. When present and valid, CAPTCHA is skipped for that client. This header is already accepted (and ignored) today so integrations can set it unconditionally.\n- **Honeypot-only mode** — CAPTCHA may be disabled entirely and replaced with honeypot + rate-limit only, preserving the current agent-compatible behaviour.\n\nNo CAPTCHA will ever be added to read endpoints or to any `/.well-known/*` discovery document.\n\n## Rate limits\n\nAll endpoints are rate-limited per client IP. Limits apply to a rolling 60-second window. When you exceed a limit you receive a `429 Too Many Requests` response with a `Retry-After` header.\n\n| Bucket | Endpoints | Requests / minute | Burst |\n|---|---|---|---|\n| `read` | `GET /api/v1/cranes`, `GET /api/v1/cranes/{id}`, all `/.well-known/*`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | 120 | 240 |\n| `write` | `POST /api/v1/inquiries`, `POST /api/v1/crane-requests`, `POST /api/v1/newsletter/subscribe` | 10 | 20 |\n\nEvery response includes advisory rate-limit headers so clients can pace their requests without triggering 429s:\n\n- `X-RateLimit-Limit` — maximum requests allowed in the current window for this bucket.\n- `X-RateLimit-Remaining` — requests remaining before the next 429.\n- `X-RateLimit-Reset` — Unix timestamp (seconds) when the window resets.\n- `X-RateLimit-Bucket` — `read` or `write`.\n- `Retry-After` — seconds to wait before retrying (sent on 429 responses only).\n\n## Errors\n\nAll error responses are JSON and follow this shape:\n\n```json\n{\n  \"error\": {\n    \"code\": \"BAD_REQUEST\",\n    \"message\": \"email must be a valid email address\",\n    \"details\": {}\n  }\n}\n```\n\n| HTTP status | `error.code` | When it happens |\n|---|---|---|\n| 400 | `BAD_REQUEST` | Malformed JSON, missing required fields, or invalid enum values. |\n| 401 | `UNAUTHENTICATED` | Reserved for future authenticated endpoints. Not used today. |\n| 403 | `FORBIDDEN` | Reserved for future authenticated endpoints. Not used today. |\n| 404 | `NOT_FOUND` | Crane or resource with the supplied id does not exist. |\n| 409 | `CONFLICT` | Resource already exists (e.g. newsletter email already subscribed). |\n| 429 | `RATE_LIMITED` | Rate-limit window exhausted. Honor `Retry-After`. |\n| 500 | `INTERNAL` | Unexpected server-side error. Safe to retry with exponential backoff. |\n| 502 | `EXTERNAL_SERVICE_ERROR` | An upstream service (e.g. email delivery) failed. |\n\n### Retry guidance\n\n- **Idempotent reads** (`GET`): retry on any `5xx` with exponential backoff starting at 1s, max 30s, up to 5 attempts.\n- **Writes** (`POST`): retry only on `429` (after `Retry-After`) or `5xx`. Do **not** retry a `POST` that returned `2xx` or `4xx` — it has already been processed or was rejected for a deterministic reason.\n\n## Discovery\n\n- MCP server manifest: [`/.well-known/mcp/manifest.json`](/.well-known/mcp/manifest.json)\n- API Catalog (RFC 9727): [`/.well-known/api-catalog`](/.well-known/api-catalog)\n- Health: [`/.well-known/health`](/.well-known/health)\n- Docs (Redoc): [`/docs`](/docs)\n- LLM index: [`/llms.txt`](/llms.txt), [`/llms-full.txt`](/llms-full.txt)\n- Security contact (RFC 9116): [`/.well-known/security.txt`](/.well-known/security.txt)",
    "contact": {
      "name": "GST Cranes",
      "url": "https://gstcranes.com/en/contact",
      "email": "info@gstcranes.com"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://gstcranes.com/en/privacy-policy"
    },
    "termsOfService": "https://gstcranes.com/en/privacy-policy"
  },
  "servers": [
    {
      "url": "https://gstcranes.com",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "description": "MCP server manifest",
    "url": "https://gstcranes.com/.well-known/mcp/manifest.json"
  },
  "security": [],
  "tags": [
    { "name": "Inventory", "description": "Browse and retrieve crane listings." },
    { "name": "Inquiries", "description": "Submit buyer inquiries and sell requests." },
    { "name": "Newsletter", "description": "Subscribe to the GST Cranes newsletter." },
    { "name": "Discovery", "description": "Well-known discovery and metadata endpoints." }
  ],
  "paths": {
    "/api/v1/cranes": {
      "get": {
        "tags": ["Inventory"],
        "summary": "List cranes",
        "description": "Returns a paginated list of available cranes, optionally filtered by type, brand, capacity, year, or price.\n\n**Rate-limit bucket:** `read` (120/min).",
        "operationId": "listCranes",
        "security": [],
        "parameters": [
          { "name": "type", "in": "query", "schema": { "type": "string", "enum": ["mobile", "crawler"] } },
          { "name": "brand", "in": "query", "schema": { "type": "string" } },
          { "name": "minCapacity", "in": "query", "schema": { "type": "number" }, "description": "Minimum lifting capacity in tonnes" },
          { "name": "maxCapacity", "in": "query", "schema": { "type": "number" } },
          { "name": "minYear", "in": "query", "schema": { "type": "integer" } },
          { "name": "maxYear", "in": "query", "schema": { "type": "integer" } },
          { "name": "maxPriceEur", "in": "query", "schema": { "type": "number" } },
          { "name": "featured", "in": "query", "schema": { "type": "boolean" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 } },
          { "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Opaque pagination cursor returned by a previous response." }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of cranes.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" },
              "X-RateLimit-Bucket": { "$ref": "#/components/headers/XRateLimitBucket" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CraneListResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Internal" }
        }
      }
    },
    "/api/v1/cranes/{id}": {
      "get": {
        "tags": ["Inventory"],
        "summary": "Get a single crane",
        "description": "Retrieve full specifications, images, and price for a single crane by ID.\n\n**Rate-limit bucket:** `read` (120/min).",
        "operationId": "getCrane",
        "security": [],
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Full crane record.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" },
              "X-RateLimit-Bucket": { "$ref": "#/components/headers/XRateLimitBucket" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Crane" } }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/inquiries": {
      "post": {
        "tags": ["Inquiries"],
        "summary": "Submit an inquiry",
        "description": "Submit a buyer inquiry about a specific crane, a sell request, or a general question.\n\n**Rate-limit bucket:** `write` (10/min). Idempotent at the message level — duplicate submissions within the window create duplicate inquiries.",
        "operationId": "submitInquiry",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/InquiryInput" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Inquiry accepted.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" },
              "X-RateLimit-Bucket": { "$ref": "#/components/headers/XRateLimitBucket" }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "id": { "type": "string" }, "status": { "type": "string", "enum": ["new"] } },
                  "required": ["id", "status"]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Internal" }
        }
      }
    },
    "/api/v1/crane-requests": {
      "post": {
        "tags": ["Inquiries"],
        "summary": "Submit a crane request",
        "description": "Register a request for a crane not currently in inventory. GST Cranes notifies the requester when a matching unit arrives.\n\n**Rate-limit bucket:** `write` (10/min).",
        "operationId": "submitCraneRequest",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/CraneRequestInput" } }
          }
        },
        "responses": {
          "201": {
            "description": "Crane request accepted.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" },
              "X-RateLimit-Bucket": { "$ref": "#/components/headers/XRateLimitBucket" }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "id": { "type": "string" } },
                  "required": ["id"]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Internal" }
        }
      }
    },
    "/api/v1/newsletter/subscribe": {
      "post": {
        "tags": ["Newsletter"],
        "summary": "Subscribe to the newsletter",
        "description": "Subscribe an email address to the GST Cranes newsletter.\n\n**Rate-limit bucket:** `write` (10/min). Returns `409 CONFLICT` if the email is already subscribed.",
        "operationId": "subscribeNewsletter",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email"],
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "name": { "type": "string" },
                  "website": {
                    "type": "string",
                    "description": "Honeypot anti-spam field. Agents and humans must leave this empty or omit it.",
                    "maxLength": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": { "description": "Subscribed." },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/.well-known/health": {
      "get": {
        "tags": ["Discovery"],
        "summary": "Service health",
        "operationId": "getHealth",
        "security": [],
        "responses": {
          "200": {
            "description": "Health status.",
            "content": {
              "application/health+json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": { "type": "string", "enum": ["ok", "degraded", "down"] },
                    "service": { "type": "string" }
                  },
                  "required": ["status"]
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/api-catalog": {
      "get": {
        "tags": ["Discovery"],
        "summary": "API catalog (RFC 9727)",
        "operationId": "getApiCatalog",
        "security": [],
        "responses": {
          "200": {
            "description": "Linkset of service descriptions and documentation.",
            "content": {
              "application/linkset+json": {
                "schema": { "type": "object" }
              }
            }
          }
        }
      }
    },
    "/.well-known/mcp/manifest.json": {
      "get": {
        "tags": ["Discovery"],
        "summary": "MCP server manifest",
        "operationId": "getMcpManifest",
        "security": [],
        "responses": {
          "200": {
            "description": "MCP server manifest document.",
            "content": {
              "application/json": { "schema": { "type": "object" } }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "OIDC": {
        "type": "openIdConnect",
        "openIdConnectUrl": "https://gstcranes.com/.well-known/openid-configuration",
        "description": "Hercules Auth (OpenID Connect). Reserved for future authenticated endpoints — not required for any endpoint in this specification today."
      }
    },
    "headers": {
      "XRateLimitLimit": {
        "description": "Maximum requests allowed in the current 60-second window for this bucket.",
        "schema": { "type": "integer", "examples": [120] }
      },
      "XRateLimitRemaining": {
        "description": "Requests remaining before the next 429 for this bucket.",
        "schema": { "type": "integer", "examples": [118] }
      },
      "XRateLimitReset": {
        "description": "Unix timestamp (seconds) when the current window resets.",
        "schema": { "type": "integer", "examples": [1767225600] }
      },
      "XRateLimitBucket": {
        "description": "Rate-limit bucket for the endpoint: `read` or `write`.",
        "schema": { "type": "string", "enum": ["read", "write"] }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying. Sent on 429 responses.",
        "schema": { "type": "integer", "examples": [30] }
      }
    },
    "schemas": {
      "Crane": {
        "type": "object",
        "required": ["id", "name", "brand", "type", "year", "priceEur", "images", "isAvailable"],
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string", "examples": ["Liebherr LTM 1130-5.1"] },
          "brand": { "type": "string", "examples": ["Liebherr", "Grove", "Tadano"] },
          "type": { "type": "string", "enum": ["mobile", "crawler"] },
          "year": { "type": "integer", "examples": [2018] },
          "priceEur": { "type": "number", "examples": [450000] },
          "capacity": { "type": "string", "description": "Lifting capacity (e.g. '130t')." },
          "hours": { "type": "integer", "description": "Total operating hours." },
          "kilometers": { "type": "integer" },
          "equipment": { "type": "string" },
          "description": { "type": "string" },
          "images": { "type": "array", "items": { "type": "string", "format": "uri" } },
          "isAvailable": { "type": "boolean" },
          "isFeatured": { "type": "boolean" },
          "url": { "type": "string", "format": "uri", "description": "Canonical HTML URL for the listing." }
        }
      },
      "CraneListResponse": {
        "type": "object",
        "required": ["data"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Crane" } },
          "nextCursor": { "type": "string", "nullable": true },
          "total": { "type": "integer" }
        }
      },
      "InquiryInput": {
        "type": "object",
        "required": ["name", "email", "message", "type"],
        "properties": {
          "name": { "type": "string", "minLength": 1 },
          "email": { "type": "string", "format": "email" },
          "company": { "type": "string" },
          "message": { "type": "string", "minLength": 1 },
          "type": { "type": "string", "enum": ["buy", "sell", "general"] },
          "craneId": { "type": "string", "description": "Required when type = 'buy' and referencing a specific listing." },
          "website": {
            "type": "string",
            "description": "Honeypot anti-spam field. Agents and humans must leave this empty or omit it. Non-empty values cause the submission to be silently discarded.",
            "maxLength": 0
          },
          "botField": {
            "type": "string",
            "description": "Second honeypot anti-spam field. Agents and humans must leave this empty or omit it.",
            "maxLength": 0
          }
        }
      },
      "CraneRequestInput": {
        "type": "object",
        "required": ["name", "email", "craneType"],
        "properties": {
          "name": { "type": "string" },
          "email": { "type": "string", "format": "email" },
          "phone": { "type": "string" },
          "craneType": { "type": "string", "enum": ["mobile", "crawler", "any"] },
          "minCapacity": { "type": "number" },
          "maxCapacity": { "type": "number" },
          "minYear": { "type": "integer" },
          "maxYear": { "type": "integer" },
          "maxBudget": { "type": "number" },
          "notes": { "type": "string" },
          "website": {
            "type": "string",
            "description": "Honeypot anti-spam field. Agents and humans must leave this empty or omit it.",
            "maxLength": 0
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "BAD_REQUEST",
                  "UNAUTHENTICATED",
                  "FORBIDDEN",
                  "NOT_FOUND",
                  "CONFLICT",
                  "RATE_LIMITED",
                  "INTERNAL",
                  "EXTERNAL_SERVICE_ERROR"
                ]
              },
              "message": { "type": "string", "description": "Human-readable error message. Safe to show to the user." },
              "details": {
                "type": "object",
                "additionalProperties": true,
                "description": "Optional machine-readable context (e.g. offending field names)."
              }
            }
          }
        },
        "examples": [
          {
            "error": {
              "code": "BAD_REQUEST",
              "message": "email must be a valid email address",
              "details": { "field": "email" }
            }
          }
        ]
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid request — malformed JSON, missing required fields, or invalid enum values.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "missingField": {
                "summary": "Missing required field",
                "value": { "error": { "code": "BAD_REQUEST", "message": "name is required", "details": {} } }
              },
              "invalidEmail": {
                "summary": "Invalid email",
                "value": { "error": { "code": "BAD_REQUEST", "message": "email must be a valid email address", "details": {} } }
              },
              "invalidEnum": {
                "summary": "Invalid enum value",
                "value": { "error": { "code": "BAD_REQUEST", "message": "type must be one of: buy, sell, general", "details": {} } }
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "crane": {
                "summary": "Crane not found",
                "value": { "error": { "code": "NOT_FOUND", "message": "Crane 'abc123' not found", "details": {} } }
              }
            }
          }
        }
      },
      "Conflict": {
        "description": "Conflict — e.g. email already subscribed to the newsletter.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "alreadySubscribed": {
                "summary": "Already subscribed",
                "value": { "error": { "code": "CONFLICT", "message": "Email is already subscribed", "details": {} } }
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests. Wait `Retry-After` seconds before retrying.",
        "headers": {
          "Retry-After": { "$ref": "#/components/headers/RetryAfter" },
          "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
          "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
          "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" },
          "X-RateLimit-Bucket": { "$ref": "#/components/headers/XRateLimitBucket" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "rateLimited": {
                "summary": "Rate limit exhausted",
                "value": { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Retry after 30 seconds.", "details": { "retryAfter": 30, "bucket": "write" } } }
              }
            }
          }
        }
      },
      "Internal": {
        "description": "Unexpected server error. Safe to retry with exponential backoff.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "internal": {
                "summary": "Internal error",
                "value": { "error": { "code": "INTERNAL", "message": "Unexpected error", "details": {} } }
              }
            }
          }
        }
      }
    }
  }
}
