{"openapi":"3.0.3","info":{"title":"Lightspeed Golf Middleware API","description":"Caching middleware that sits between the front-end booking application and\nthe Lightspeed Golf Partner API v2.\n\n## Purpose\nThe middleware solves rate-limiting constraints from Lightspeed's pull-based\nAPI by implementing intelligent caching, demand-adaptive polling, and safe\nmulti-system booking orchestration.\n\n## Architecture\n```\nFrontend (browser) ──► Middleware (port 3002) ──► Lightspeed Partner API\n                                │\n                                ├──► Storefront (port 3003)\n                                ├──► Email Service (port 3004)\n                                ├──► Redis (hot cache / state)\n                                └──► Postgres (durable store)\n```\n\n## Key Capabilities\n- **Tee-time caching** with Stale-While-Revalidate (SWR) semantics and\n  demand-adaptive TTLs\n- **Real-time updates** via Server-Sent Events (SSE) for live availability\n- **Multi-system booking orchestration** with optimistic locking, idempotency,\n  pre-booking validation, and automatic compensation on partial failure\n- **Stripe payment integration** with automatic refunds on booking failure\n- **Refund settlement** — cash refunds via Stripe or voucher issuance\n- **Booking and cancellation audit logs** for admin visibility\n- **Tenant theming** — per-property brand, colours, fonts, and labels\n- **Package and item support** — proxied catalogue and expansion from the Storefront\n- **Waiting list** — day-based customer watch list with email notification\n- **Webhook destinations** — configurable outbound event delivery with SSRF protection\n- **Email routing** — proxied email connector management and template rendering\n- **Multi-tenant admin** — super-admin and tenant-scoped credential and config management\n\n## Authentication\nWhen the `MIDDLEWARE_API_KEY` environment variable is set, all endpoints\n(except `/health`, `/metrics`, and `/webhooks/*`) require an `X-API-Key` header.\n\nAdmin endpoints under `/admin/*` require an `Authorization: Bearer <JWT>` header.\nThe middleware verifies JWTs against Supabase (super-admins) or a linked\ntenant-configured alternative identity provider (tenant admins).\nAdmin scope is either `super` (all orgs) or `tenant` (single org).\n","version":"2.0.0","contact":{"name":"Platform Team"}},"servers":[{"url":"http://localhost:3002","description":"Local development"}],"tags":[{"name":"Agent Discovery","description":"Unauthenticated agent-discoverability manifests: llms.txt, the\nA2A agent card (/.well-known/agent.json), and the public OpenAPI\n(/openapi.json). Published only when AGENT_DISCOVERY_ENABLED is set.\n"},{"name":"Public API","description":"Open, self-serve booking API for aggregators, tour operators\nand AI agents. Mint an anonymous consumer key at POST /public-api/keys,\nthen read venues (directory:read), availability (availability:read), and\nquote + build a prefilled storefront handoff link (bookings:quote). All\nbookings COMPLETE ON THE VENUE STOREFRONT — this API never creates, pays\nfor, or cancels a booking. Only venues with discovery enabled\n(public_api_enabled, on by default for new venues), live and non-shadowed\nare listable/quotable; public rates only.\n\nRate limits: every request is checked against per-second,\nper-minute and per-10-minute sliding windows together (defaults per key:\n10/s, 120/min, 2000/10 min — platform-adjustable per key), backed by a\nper-source-address aggregate; key issuance is far tighter (3/min,\n10/10 min per address). A 429 carries a Retry-After header with the\nseconds until the tightest blocked window clears.\n"}],"paths":{"/llms.txt":{"get":{"tags":["Agent Discovery"],"summary":"Plain-text service map for AI agents","operationId":"llmsTxt","description":"A pointer document (base URL, auth, scopes, doc links). Public. 404s when AGENT_DISCOVERY_ENABLED is off.","responses":{"200":{"description":"The llms.txt service map","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"Discovery disabled"}}}},"/.well-known/agent.json":{"get":{"tags":["Agent Discovery"],"summary":"A2A agent card","operationId":"agentCard","description":"Identity, booking skills, and auth scheme as JSON. Public. 404s when discovery is off.","responses":{"200":{"description":"The agent card","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Discovery disabled"}}}},"/openapi.json":{"get":{"tags":["Agent Discovery"],"summary":"Public OpenAPI document","operationId":"publicOpenApi","description":"The full OpenAPI spec, served unauthenticated for agents. Public. 404s when discovery is off.","responses":{"200":{"description":"The OpenAPI document","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Discovery disabled"},"503":{"description":"Spec unavailable"}}}},"/public-api/keys":{"post":{"tags":["Public API"],"summary":"Mint an anonymous public consumer key","operationId":"publicApiMintKey","description":"Issues an anonymous consumer key carrying directory:read,\navailability:read and bookings:quote. No auth, no vetting, no contact\ncapture — the API is open and rate limiting is the control. The raw key\nis returned ONCE. Issuance is rate-limited per source address\n(defaults 3/min and 10/10 min) — reuse one key per integration; minting\nkeys in bulk yields no extra request budget.\n","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string","maxLength":80,"description":"Optional holder-side label; never used for vetting"}}}}}},"responses":{"201":{"description":"Key issued (raw key shown once).","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"integer"},"key":{"type":"string","description":"Raw key — store it now; never retrievable again"},"label":{"type":"string"},"scopes":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"}}}}}},"429":{"$ref":"#/components/responses/PublicKeyIssuanceRateLimited"},"503":{"description":"Database unavailable"}}}},"/integrations/directory/venues":{"get":{"tags":["Public API"],"summary":"List participating venues","operationId":"publicDirectoryList","description":"Lists live, opted-in venues. Filter by name/slug (q) or by place: a\nlat/lng point (results carry distanceKm and sort by distance, with an\noptional radiusKm), or a published region/country. Returns only public\nvenue metadata (incl. coarse location the venue chose to publish) — no\ncustomer data. Venues without coordinates are excluded from a lat/lng\nquery but included in name/region/country queries. Requires directory:read.\n","security":[{"BearerAuth":[]}],"parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Name/slug substring match"},{"name":"lat","in":"query","schema":{"type":"number"},"description":"Query point latitude (WGS-84); requires lng"},{"name":"lng","in":"query","schema":{"type":"number"},"description":"Query point longitude (WGS-84); requires lat"},{"name":"radiusKm","in":"query","schema":{"type":"number"},"description":"With lat/lng","drop venues beyond this distance":null},{"name":"region","in":"query","schema":{"type":"string"},"description":"Fuzzy match on published address region: case/punctuation-insensitive, accepts common abbreviations (Herts) and partials (Hertford), without matching unrelated counties"},{"name":"country","in":"query","schema":{"type":"string"},"description":"ISO 3166-1 alpha-2 match on published country"},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"pageSize","in":"query","schema":{"type":"integer","default":25,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"A page of visible venues.","content":{"application/json":{"schema":{"type":"object","properties":{"venues":{"type":"array","items":{"$ref":"#/components/schemas/PublicVenue"}},"page":{"type":"integer"},"pageSize":{"type":"integer"},"total":{"type":"integer"}}}}}},"401":{"$ref":"#/components/responses/ServiceKeyUnauthorized"},"403":{"description":"Key lacks directory:read or is not entitled"},"429":{"$ref":"#/components/responses/PublicRateLimited"}}}},"/integrations/directory/venues/{orgId}":{"get":{"tags":["Public API"],"summary":"Look up one participating venue","operationId":"publicDirectoryGet","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/OrgIdPath"}],"responses":{"200":{"description":"The venue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicVenue"}}}},"401":{"$ref":"#/components/responses/ServiceKeyUnauthorized"},"404":{"description":"Not found / not opted in / shadowed"},"429":{"$ref":"#/components/responses/PublicRateLimited"}}}},"/integrations/search":{"get":{"tags":["Public API"],"summary":"Cross-tenant availability search","operationId":"publicSearch","description":"Find venues with availability on a date (or short date range) across the\nplatform, filtered by place (lat/lng + radiusKm, or region/country, or a\nname/slug substring q) and ranked by distance (default when a point is\ngiven) or by open slots (sort=availability). Day-level availability is\ncache-backed and indicative — the same summaries as\nGET /integrations/availability/{orgId} — and never triggers a live\nprovider call on the request path. Drill into a chosen venue with\nGET /integrations/teetimes/{orgId}/{date} for slot prices, then complete\non the storefront. The candidate set is capped (limit) before availability\nis read; when it is capped, `truncated` is true and `candidateCount` gives\nthe pre-cap match count. Requires availability:read.\n","security":[{"BearerAuth":[]}],"parameters":[{"name":"date","in":"query","schema":{"type":"string"},"description":"Single date YYYY-MM-DD (use this OR from/to)"},{"name":"from","in":"query","schema":{"type":"string"},"description":"Range start YYYY-MM-DD (with to)"},{"name":"to","in":"query","schema":{"type":"string"},"description":"Range end YYYY-MM-DD (with from); range ≤ 31 days"},{"name":"lat","in":"query","schema":{"type":"number"},"description":"Query point latitude; requires lng"},{"name":"lng","in":"query","schema":{"type":"number"},"description":"Query point longitude; requires lat"},{"name":"radiusKm","in":"query","schema":{"type":"number"},"description":"With lat/lng","drop venues beyond this distance":null},{"name":"region","in":"query","schema":{"type":"string"},"description":"Fuzzy match on published region: case/punctuation-insensitive, accepts abbreviations (Herts) and partials (Hertford), without matching unrelated counties"},{"name":"country","in":"query","schema":{"type":"string"},"description":"ISO 3166-1 alpha-2 match on published country"},{"name":"q","in":"query","schema":{"type":"string"},"description":"Name/slug substring match"},{"name":"players","in":"query","schema":{"type":"integer","minimum":1},"description":"Party size. Day-level filter: keeps venues with one tee time that has at least this many free player places on a requested day (not a slot-level guarantee). Invalid/≤0 ignored."},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"minimum":1,"maximum":100},"description":"Max venues returned"},{"name":"sort","in":"query","schema":{"type":"string","enum":["distance","availability"]},"description":"Ordering; distance is default when a point is given"}],"responses":{"200":{"description":"Matched venues with per-date cache-backed availability.","content":{"application/json":{"schema":{"type":"object","properties":{"venues":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/PublicVenue"},{"type":"object","properties":{"availability":{"type":"object","description":"Per-date summary keyed by YYYY-MM-DD (see GET /integrations/availability/{orgId})."}}}]}},"dates":{"type":"array","items":{"type":"string"}},"count":{"type":"integer"},"truncated":{"type":"boolean","description":"True when the candidate set exceeded limit"},"candidateCount":{"type":"integer","description":"Pre-cap match count (present only when truncated)"},"filteredByPlayers":{"type":"integer","description":"Venues read but dropped because no requested day had one tee time that could seat `players` (present only when the players filter narrowed the result)"},"playersCountIsPartial":{"type":"boolean","description":"True when a players filter ran on a truncated candidate set: more group-capable venues may exist beyond `limit` but were not read. Treat `count` as a floor; narrow by place or raise `limit` to see more."}}}}}},"400":{"description":"Missing/invalid date or range too long"},"401":{"$ref":"#/components/responses/ServiceKeyUnauthorized"},"403":{"description":"Key lacks availability:read or is not entitled"},"429":{"$ref":"#/components/responses/PublicRateLimited"}}}},"/integrations/availability/{orgId}":{"get":{"tags":["Public API"],"summary":"Day-level availability (public)","operationId":"publicAvailability","description":"Cache-backed day summaries for a participating venue. Same response as\nGET /availability/{orgId}. Requires availability:read and a participating\n(live, opted-in) venue.\n","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/OrgIdPath"},{"name":"from","in":"query","required":true,"schema":{"type":"string"}},{"name":"to","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Per-date availability summary (see GET /availability/{orgId})."},"401":{"$ref":"#/components/responses/ServiceKeyUnauthorized"},"403":{"description":"Key lacks availability:read or is not entitled"},"404":{"description":"Venue not participating"},"429":{"$ref":"#/components/responses/PublicRateLimited"}}}},"/integrations/teetimes/{orgId}/{date}":{"get":{"tags":["Public API"],"summary":"Slot-level tee times with public prices","operationId":"publicTeetimes","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/OrgIdPath"},{"name":"date","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Tee times with resolved public prices (see GET /teetimes/{orgId}/{date})."},"401":{"$ref":"#/components/responses/ServiceKeyUnauthorized"},"404":{"description":"Venue not participating"},"429":{"$ref":"#/components/responses/PublicRateLimited"}}}},"/integrations/changes/{orgId}/{date}":{"get":{"tags":["Public API"],"summary":"SSE stream of tee-time changes (public)","operationId":"publicChanges","description":"Same SSE stream as GET /changes/{orgId}/{date}, for a participating\nvenue. Prefer this over tight polling — one open stream does not\nconsume the per-request rate budget. Requires availability:read.\n","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/OrgIdPath"},{"$ref":"#/components/parameters/DatePath"}],"responses":{"200":{"description":"SSE event stream (see GET /changes/{orgId}/{date})."},"401":{"$ref":"#/components/responses/ServiceKeyUnauthorized"},"404":{"description":"Venue not participating"},"429":{"$ref":"#/components/responses/PublicRateLimited"}}}},"/integrations/catalog/{orgId}":{"get":{"tags":["Public API"],"summary":"Aggregated catalog (courses, player types, products)","operationId":"publicCatalog","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/OrgIdPath"}],"responses":{"200":{"description":"Catalog for the venue.","content":{"application/json":{"schema":{"type":"object","properties":{"courses":{"type":"array","items":{"type":"object"}},"playerTypes":{"type":"array","items":{"type":"object"}},"products":{"type":"array","items":{"type":"object"}}}}}}},"401":{"$ref":"#/components/responses/ServiceKeyUnauthorized"},"404":{"description":"Venue not participating"},"429":{"$ref":"#/components/responses/PublicRateLimited"}}}},"/integrations/bookings/{orgId}/quote":{"post":{"tags":["Public API"],"summary":"Server-authoritative price quote","operationId":"publicBookingQuote","description":"Prices a slot at public rates (no member assertion) using the same\ncalculator the storefront /book path uses. Indicative only — no slot is\nheld; the storefront re-prices at checkout. Requires bookings:quote and a\nparticipating venue.\n","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/OrgIdPath"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["teetimeId","date","playerCount"],"properties":{"teetimeId":{"type":"string"},"date":{"type":"string"},"playerCount":{"type":"integer","minimum":1,"maximum":4},"playerTypeId":{"type":"string"}}}}}},"responses":{"200":{"description":"The quote.","content":{"application/json":{"schema":{"type":"object","properties":{"orgId":{"type":"string"},"teetimeId":{"type":"string"},"date":{"type":"string"},"playerCount":{"type":"integer"},"playerTypeId":{"type":"string"},"currency":{"type":"string","description":"ISO 4217, lowercase (e.g. gbp). Per-venue."},"greenFeePerPlayer":{"type":"number","description":"Major units (pounds). Back-compat."},"total":{"type":"number","description":"Major units (pounds). Back-compat."},"greenFeePerPlayerMinor":{"type":"integer","description":"Per-player green fee in minor units (pence/cents)."},"totalMinor":{"type":"integer","description":"Total in minor units (pence/cents)."},"totals":{"type":"array","description":"Itemized totals (subtotal + total). Amounts in minor units. Tax/fee/discount are not resolved at quote time, so no such lines are asserted here.","items":{"type":"object","properties":{"type":{"type":"string","enum":["subtotal","total"]},"display_text":{"type":"string"},"amount":{"type":"integer","description":"Minor units (pence/cents)."}}}},"priceSource":{"type":"string","enum":["lightspeed","override"]},"held":{"type":"boolean","enum":[false]}}}}}},"400":{"description":"Missing/invalid fields"},"401":{"$ref":"#/components/responses/ServiceKeyUnauthorized"},"404":{"description":"Venue not participating","or tee time/player type not found":null},"429":{"$ref":"#/components/responses/PublicRateLimited"}}}},"/integrations/bookings/{orgId}/handoff-link":{"get":{"tags":["Public API"],"summary":"Build a prefilled storefront handoff link","operationId":"publicBookingHandoffLink","description":"Returns a canonical storefront deep-link URL landing on the final\npayment/confirm step, prefilled from the validated query params and\ntagged with a gk_channel attribution token. The customer completes the\nbooking on the venue storefront. Requires bookings:quote.\n","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/OrgIdPath"},{"name":"date","in":"query","required":true,"schema":{"type":"string"}},{"name":"time","in":"query","schema":{"type":"string"}},{"name":"players","in":"query","schema":{"type":"integer","minimum":1,"maximum":4}},{"name":"holes","in":"query","schema":{"type":"integer","enum":[9,18]}},{"name":"rate","in":"query","schema":{"type":"string","enum":["standard","package"]}},{"name":"course","in":"query","schema":{"type":"string"}},{"name":"name","in":"query","schema":{"type":"string"}},{"name":"email","in":"query","schema":{"type":"string"}},{"name":"phone","in":"query","schema":{"type":"string"}},{"name":"notes","in":"query","schema":{"type":"string"}},{"name":"extras","in":"query","schema":{"type":"string"},"description":"id:qty,id:qty"}],"responses":{"200":{"description":"The handoff URL.","content":{"application/json":{"schema":{"type":"object","properties":{"orgId":{"type":"string"},"url":{"type":"string"},"step":{"type":"string","enum":["final","date"]},"channel":{"type":"string"}}}}}},"400":{"description":"A valid date is required"},"401":{"$ref":"#/components/responses/ServiceKeyUnauthorized"},"404":{"description":"Venue not participating"},"429":{"$ref":"#/components/responses/PublicRateLimited"}}}}},"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"Service key for internal event ingest (sk_ prefix)"}},"responses":{"PublicKeyIssuanceRateLimited":{"description":"Key issuance rate limited. Minting is metered per source\naddress, far tighter than reads (defaults 3/min and 10/10 min) — a\nminted key is quota, and read limits are also backed by a per-address\naggregate, so bulk-minting keys yields no extra budget. Reuse one key\nper integration and retry after the indicated delay.\n","headers":{"Retry-After":{"description":"Seconds until the tightest (soonest-to-clear) blocked window frees a slot.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ServiceKeyUnauthorized":{"description":"Missing or invalid service key (Authorization: Bearer <key>).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"PublicRateLimited":{"description":"Rate limited. Public API requests are metered over three\nsliding windows checked together — per-second, per-minute and\nper-10-minutes — and a request is admitted only when EVERY window has\nheadroom, so short bursts pass while sustained volume trips. Two\nmeters apply: the authenticated key's windows (defaults 10/s, 120/min,\n2000/10 min; the platform can adjust these per key — contact GolfKit\nfor higher limits) and a per-source-address aggregate that also counts\nkeyed traffic (so rotating keys does not raise an address's budget).\nRetry after the indicated delay.\n","headers":{"Retry-After":{"description":"Seconds until the tightest (soonest-to-clear) blocked window frees a slot.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"PublicVenue":{"type":"object","description":"A participating venue as seen through the public directory — public\nmetadata already on the tenant record, plus coarse published location.\nlocation and geo appear only when the venue has published them;\ndistanceKm appears only when the query supplied a lat/lng point.\n","required":["orgId","name","status","bookable"],"properties":{"orgId":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string","nullable":true},"timezone":{"type":"string","nullable":true},"status":{"type":"string","enum":["live","paused"]},"bookable":{"type":"boolean","description":"True only when status is live; paused venues list but are not quotable."},"location":{"type":"object","description":"Coarse public location (never the street line). Present only when published.","properties":{"locality":{"type":"string","description":"Town / city"},"region":{"type":"string","description":"County / state / administrative area"},"countryCode":{"type":"string","description":"ISO 3166-1 alpha-2","uppercase":null}}},"geo":{"type":"object","description":"WGS-84 coordinates. Present only when published.","properties":{"latitude":{"type":"number"},"longitude":{"type":"number"}}},"distanceKm":{"type":"number","description":"Kilometres from the query point. Present only when the query supplied lat/lng."}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}}},"parameters":{"OrgIdPath":{"name":"orgId","in":"path","required":true,"schema":{"type":"string"},"description":"Organisation / tenant identifier"},"DatePath":{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date"},"description":"Date in YYYY-MM-DD format"}}}}