{"openapi":"3.1.0","info":{"title":"Bota Standard Provider API","version":"1.5.0","description":"API contract for car rental management systems (RMS) to connect with Bota.\nYou (the RaC) **host** these endpoints; Bota **calls** them in real time during a\ncustomer conversation. Implement the required endpoints, point Bota at your Base\nURL, and verify with the live diagnostic until every endpoint is green.\n\n## Authentication & token bootstrap\nAuth is a single Bearer token. The direction matters: **you generate the token in\nthe Bota settings panel** (Settings → RMS → API Estándar → \"Generate token\"). It is\nshown once — copy it into your RMS configuration. Bota then sends it on **every**\nrequest, and your endpoint MUST require exactly that value:\n```\nAuthorization: Bearer {token-from-bota}\n```\nOne token maps to one tenant/connection. **Rotate** by generating a new token in\nthe panel and updating your RMS; reject anything else with `401` and the `Error`\nenvelope. (You may also paste your own pre-existing secret instead of generating\none — either way, require exactly the configured value.)\n\n## Required vs optional endpoints\nRequired: `/health`, `/offices`, `/availability`, `/extras`, and the\n`/bookings*` lifecycle. Optional (preferred when supported): `/concepts`\n(3-bucket concepts) and `/rate-rules` (bulk OTA_VehRateRule). If unimplemented,\nBota degrades gracefully and flags `diagnostics.fallbackUsed`.\n\n## Request lifecycle (how the ids flow)\nEach response hands the next request its ids — follow one transaction in the\nwalkthrough below:\n1. `GET /offices` → take an office `id` as **`officeId`** (and `returnOfficeId`\n   for one-way).\n2. `POST /availability` → from the chosen offer carry **`groupId`**,\n   **`tariffId`**, and **`rateRef`** forward.\n3. `tariffId` is the pricing key: pass it to `GET /extras?tariffId=`,\n   `GET /concepts?tariffId=`, and in `tariffIds[]` of `POST /rate-rules`.\n4. `POST /bookings` takes `officeId` + `groupId` + `tariffId` (+ `rateRef`) +\n   the customer block → returns **`bookingId`** + `status`.\n5. `bookingId` feeds `GET /bookings/{bookingId}`, `.../cancel`, `.../modify`.\n\n**Roles of the three offer ids:** `groupId` = the vehicle group, `tariffId` = the\nrate plan, `rateRef` = an opaque token that **locks the quoted price/availability**\nfor that specific offer. Echo `rateRef` on `/bookings` to honour the quoted price.\nOmitting it (or a stale one) is allowed but means \"best price now\" — you may then\nreturn `reprice_required` (see below).\n\n## Dates & timezones\n`pickupDate`/`dropoffDate` are ISO-8601 calendar dates (`YYYY-MM-DD`; a full\ndate-time is tolerated but the authoritative time-of-day is the separate\n`pickupTime`/`dropoffTime`, `\"HH:MM\"`). **Interpret all of them in the office's\nlocal timezone** (`Office.timezone`) — never UTC-shift. A counter opens at the\noffice's wall-clock time, so \"10:00\" means 10:00 local at that station. Because\nthis rule hangs entirely on `Office.timezone`, **always send it on every office** —\nit is schema-optional but a missing zone risks off-by-hours errors on cross-zone or\none-way rentals.\n\n## Money & pricing\nAll amounts are **integer cents**; `currency` is mandatory ISO-4217 (3 letters,\nno EUR fallback). `totalCents` on a booking is the **all-in rental price the\ncustomer pays** — it includes selected extras, mandatory/required concepts, and\nany one-way surcharge. It does **not** include the refundable **deposit**, which is\na separate authorization surfaced as `depositCents` on the booking detail. It may\ndiffer from `Offer.totalPriceCents` once extras are added or after a reprice. On a\n`selectedExtras` line, `priceCents` is the price you accept for that line for the\nwhole rental (typically the extra's `priceTotalCents`); `qty` is informational —\n**your server is authoritative** for the final `totalCents`.\n\n## Reprice flow (Bota extension over OTA)\n`POST /bookings` may return `status: \"reprice_required\"` with a new `totalCents`\ninstead of failing when the accepted price is stale (offer expired, dates moved,\navailability changed). No reservation is created. To recover: surface the new price\nto the customer, and once they accept, **re-POST the same booking with\n`expectedTotalCents` set to the `totalCents` you just received**. That field is how\nyour second call signals \"customer accepted the new price\" — without it you would\nloop. The same `Idempotency-Key` is fine since nothing was booked yet. Confirm when\nyour live price equals `expectedTotalCents` (within your tolerance); reprice again\nonly if it moved further.\n\n## Idempotency\n`POST /bookings` carries an `Idempotency-Key` header — stable across retries of\nthe same attempt. You MUST dedupe on it: return the original booking for a repeated\nkey instead of creating a second reservation. A network blip + retry must never\ndouble-book.\n\n## On-request offices & booking status\n`BookingResult.status` has exactly two values: `confirmed` and\n`reprice_required` — there is **no asynchronous \"pending\" state** in this contract.\nEven for an `isOnRequestOffice`, `POST /bookings` must resolve **synchronously**:\nreturn `confirmed` once you have provisionally secured the vehicle (and reconcile\non your side), or fail with the `Error` envelope if you cannot. `GET /bookings/{id}`\nlater reports the lifecycle state (`confirmed` / `cancelled` / `modified`).\n\n## Errors & codes\nReturn the `Error` envelope `{ error, code?, details? }` with the matching HTTP\nstatus: `401/403` auth, `404` not found, `409` conflict\n(`BOOKING_UNAVAILABLE` / `INVENTORY_DEPLETED`), `422` `VALIDATION_ERROR`,\n`5xx` internal. `code` lets Bota render a precise customer message.\nNote: `PRICE_CHANGED` (a hard `409` error, booking abandoned) is distinct from the\n`reprice_required` **success** status (a recoverable `200`/`201`) — prefer\n`reprice_required` so the chat can re-prompt instead of restarting.\n\n## Call model & limits\nBota calls you in real time, one customer at a time — low QPS per tenant, not bulk\ntraffic. There is **no pagination**: return the **complete** set for `/offices` and\n`/availability` (catalogs are bounded — a station list, the offers for one search).\nEach request has a **~15 s timeout**; respond within it or the diagnostic flags the\nendpoint as slow. Bota throttles its own outbound calls; you do not need to return\nrate-limit headers.\n\n## Webhooks (optional, outbound from Bota)\nConfigure a `webhookUrl` (+ a `webhookSecret`, and optionally `webhookEvents` to\nfilter) in the Bota panel; use **\"Send test event\"** there to fire a signed `test`\ndelivery at your URL. Bota then POSTs booking events carrying our\n`confirmationCode` + distribution channel — attribution your own RMS call does not\nhave. Body shape: `{ id, event, timestamp, data }` (see the typed\n`WebhookEventConfirmed` / `...Cancelled` / `...Modified` schemas — `data` is\nfully typed per event). Headers `X-Bota-Event`, `X-Bota-Delivery-Id`,\n`X-Bota-Timestamp` mirror the body for pre-parse handling but are **advisory and\nunsigned**. `X-Bota-Signature` = HMAC-SHA256 of the **raw body** with your\n`webhookSecret` (hex). The signed-against-replay rule: verify the signature first,\nthen **trust the body's `id` and `timestamp`** (they are inside the HMAC), not the\nheaders — dedupe on `id` and reject when `timestamp` is older than ~5 min. On\nnon-2xx/timeout Bota retries with backoff, reusing the same `id`:\n```js\nimport crypto from 'node:crypto'\nconst seen = new Set() // persist across deliveries (e.g. Redis) in production\nfunction verify(rawBody, headers, secret) {\n  const sig = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')\n  const given = headers['x-bota-signature'] || ''\n  if (sig.length !== given.length ||\n      !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(given))) return false\n  const { id, timestamp } = JSON.parse(rawBody) // SIGNED fields — trust these\n  if (Date.now() - Date.parse(timestamp) > 5 * 60 * 1000) return false // replay\n  if (seen.has(id)) return false // duplicate delivery → no-op\n  seen.add(id)\n  return true\n}\n```\n\n## Getting started\nYou do NOT need our repository to integrate or to validate — everything is on\nthis page plus the live diagnostic in the Bota panel.\n\n1. Implement the required endpoints (add the optional ones for richer chat) — copy\n   the reference handlers below and adapt them to your RMS.\n2. Generate a token and configure your Base URL in the Bota settings panel. The\n   Base URL is the public HTTPS root where **you** host these endpoints (e.g.\n   `https://api.your-rms.com/bota`) — Bota calls it; it must be HTTPS, no\n   localhost / private IPs.\n3. Click \"Connect & verify\" — this runs Bota's **authoritative** diagnostic (the\n   exact runtime schemas) against your endpoint, reporting each one until every\n   check is green. No repo, no setup beyond your URL + token.\n4. (Optional) Configure a webhook URL and fire a test event.\n5. Start receiving bookings.\n\n## Validate in your own CI\nFor a pre-flight smoke test, download the standalone, zero-dependency conformance\nscript and run it with Node:\n```bash\ncurl -sL https://api.bota-chat.com/api/public/provider-api-devkit -o bota-conformance.mjs\nBASE_URL=https://api.your-rms.com/bota API_KEY=your-token node bota-conformance.mjs\n# add the booking lifecycle (creates + cancels a real reservation):\nCONFORMANCE_BOOKINGS=1 BASE_URL=... API_KEY=... node bota-conformance.mjs\n```\nIt checks response shapes (required fields); \"Connect & verify\" remains the\nauthoritative validator.\n\n## End-to-end walkthrough (curl)\n\nOne transaction, ids threaded through. Replace `$TOKEN` with the token you\ngenerated in the Bota settings panel and `$BASE` with your Base URL.\n\n```bash\nTOKEN=\"bota_rms_xxxxxxxxxxxxxxxxxxxx\"   # the token Bota presents on every call\nBASE=\"https://api.your-rms.com/bota\"\n\n# 1. Health — verifies URL + token during setup\ncurl -s \"$BASE/health\" -H \"Authorization: Bearer $TOKEN\"\n# -> {\"ok\":true}\n\n# 2. Offices — take an office id for officeId\ncurl -s \"$BASE/offices\" -H \"Authorization: Bearer $TOKEN\"\n# -> {\"offices\":[{\"id\":\"office-pmi\",\"code\":\"PMI\",\"name\":\"Palma Airport\",...}]}\n\n# 3. Availability — officeId in; groupId + tariffId + rateRef out\ncurl -s -X POST \"$BASE/availability\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"officeId\":\"office-pmi\",\"pickupDate\":\"2026-07-15\",\"dropoffDate\":\"2026-07-22\",\n       \"pickupTime\":\"10:00\",\"dropoffTime\":\"10:00\",\"driverAge\":30,\"locale\":\"es-ES\"}'\n# -> {\"offers\":[{\"groupId\":\"compact\",\"tariffId\":\"t1\",\"rateRef\":\"r1\",\n#               \"totalPriceCents\":25000,\"currency\":\"EUR\",...}]}\n\n# 4. Rate rules — conditions/concepts for the chosen tariffId (optional, preferred)\ncurl -s -X POST \"$BASE/rate-rules\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"tariffIds\":[\"t1\"],\"rentalContext\":{\"pickupDate\":\"2026-07-15\",\n       \"dropoffDate\":\"2026-07-22\",\"driverAge\":30}}'\n\n# 5. Create booking — thread officeId+groupId+tariffId+rateRef; echo the price you\n#    accepted as expectedTotalCents; send a stable Idempotency-Key\ncurl -s -X POST \"$BASE/bookings\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: bota-booking-7f3c1e9a\" \\\n  -d '{\"officeId\":\"office-pmi\",\"pickupDate\":\"2026-07-15\",\"dropoffDate\":\"2026-07-22\",\n       \"groupId\":\"compact\",\"tariffId\":\"t1\",\"rateRef\":\"r1\",\n       \"customer\":{\"givenName\":\"María\",\"surname\":\"García\",\"email\":\"maria@example.com\"},\n       \"expectedTotalCents\":25000}'\n# -> {\"bookingId\":\"BK-1\",\"status\":\"confirmed\",\"totalCents\":25000,\"currency\":\"EUR\"}\n#\n# If status is \"reprice_required\": the price moved. Re-POST the SAME request with\n# expectedTotalCents set to the totalCents you just received, after the customer\n# accepts. Same Idempotency-Key is fine — you have not booked anything yet.\n\n# 6. Read it back — bookingId from step 5\ncurl -s \"$BASE/bookings/BK-1\" -H \"Authorization: Bearer $TOKEN\"\n\n# 7. Cancel — bookingId from step 5\ncurl -s -X POST \"$BASE/bookings/BK-1/cancel\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"reason\":\"customer_request\"}'\n# -> {\"bookingId\":\"BK-1\",\"status\":\"cancelled\"}\n```\n\n## Minimal reference handlers\n\nCopy-paste server skeletons for the endpoints Bota calls. Every request carries\n`Authorization: Bearer <token>` — require exactly the token you configured.\nMoney is integer cents, `currency` is mandatory ISO-4217, redirects are\ndisabled (return absolute URLs). Replace the in-memory stubs with your RMS.\n\n### Node.js (Express)\n\n```js\nconst express = require('express'); const crypto = require('crypto');\nconst app = express();\napp.use(express.json({ verify: (req, _r, buf) => { req.rawBody = buf } })); // raw body for webhooks\nconst TOKEN = process.env.BOTA_TOKEN, SECRET = process.env.BOTA_WEBHOOK_SECRET;\nconst bookings = new Map(), idem = new Map();\nconst eq = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));\n\napp.use((req, res, next) => {                          // Bearer auth on every request\n  const t = (req.get('authorization') || '').replace(/^Bearer\\s+/i, '');\n  if (!TOKEN || !eq(t, TOKEN)) return res.status(401).json({ error: 'unauthorized', code: 'AUTH' });\n  next();\n});\napp.get('/health', (_q, res) => res.json({ ok: true }));\napp.get('/offices', (_q, res) => res.json({ offices: [/* your stations */] }));\napp.post('/availability', (req, res) => res.json({ offers: [/* price req.body */] }));\napp.post('/bookings', (req, res) => {\n  const key = req.get('idempotency-key');\n  if (key && idem.has(key)) return res.json(bookings.get(idem.get(key)));  // original on repeat\n  const id = 'BK-' + crypto.randomUUID();\n  const b = { bookingId: id, status: 'confirmed', totalCents: req.body.expectedTotalCents, currency: 'EUR' };\n  bookings.set(id, b); if (key) idem.set(key, id);\n  res.status(201).json(b);\n});\n// Verifying the webhooks Bota sends YOU (booking.confirmed/cancelled/modified):\nfunction verifyBotaWebhook(rawBody, ts, sig) {\n  if (!ts || Math.abs(Date.now() - Date.parse(ts)) > 5 * 60_000) return false; // > 5 min skew\n  return eq(crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex'), sig || '');\n}\napp.listen(3000);\n```\n\n### PHP\n\n```php\n<?php\n$TOKEN = getenv('BOTA_TOKEN'); $SECRET = getenv('BOTA_WEBHOOK_SECRET');\n$raw = file_get_contents('php://input'); $body = json_decode($raw, true) ?: [];\n$path = strtok($_SERVER['REQUEST_URI'], '?'); $method = $_SERVER['REQUEST_METHOD'];\nheader('Content-Type: application/json');\nfunction send($c, $d) { http_response_code($c); echo json_encode($d); exit; }\n\n$tok = preg_replace('/^Bearer\\s+/i', '', $_SERVER['HTTP_AUTHORIZATION'] ?? '');\nif (!$TOKEN || !hash_equals($TOKEN, $tok)) send(401, ['error' => 'unauthorized', 'code' => 'AUTH']);\n\nif ($path === '/health') send(200, ['ok' => true]);\nif ($path === '/offices') send(200, ['offices' => [/* your stations */]]);\nif ($path === '/availability' && $method === 'POST') send(200, ['offers' => [/* price $body */]]);\nif ($path === '/bookings' && $method === 'POST') {\n  $key = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? '';\n  $store = sys_get_temp_dir() . '/bota_' . sha1($key) . '.json';\n  if ($key && is_file($store)) send(200, json_decode(file_get_contents($store), true)); // original on repeat\n  $b = ['bookingId' => 'BK-' . bin2hex(random_bytes(8)), 'status' => 'confirmed',\n        'totalCents' => $body['expectedTotalCents'] ?? 0, 'currency' => 'EUR'];\n  if ($key) file_put_contents($store, json_encode($b));\n  send(201, $b);\n}\n// Verify the webhooks Bota sends YOU:\nfunction verify_bota_webhook($raw, $ts, $sig, $secret) {\n  if (!$ts || abs(time() - strtotime($ts)) > 300) return false;            // > 5 min skew\n  return hash_equals(hash_hmac('sha256', $raw, $secret), $sig ?? '');       // constant-time\n}\n```\n\n## Changelog\n- **1.5.0** — Sellable coverage tiers: `Extra` gains optional insurance fields\n  (`insuranceKind`, `deductibleCents`, `depositCents`, `depositReductionCents`,\n  `coverageExclusive`, `preselected`) so excess-reduction levels are sold as\n  structured tiers (radio UI + upsell with real numbers); the picked tier\n  travels back as a regular `selectedExtras` line. `tariffConditions` gains\n  `depositCents`/`depositCurrency` (pre-booking hold, verbalised at the extras\n  step). Documented the canonical `fuelPolicy` codes (`full_to_full`,\n  `full_to_empty`, `same_to_same` — localized on the confirmation card) and a\n  normalized open-set `concept` code on extras + included/required concepts.\n  All additive/back-compat: 1.4.0 payloads parse unchanged.\n- **1.4.0** — Hardened webhooks: each event carries a stable `id` (also\n  `X-Bota-Delivery-Id`) for idempotent dedupe, and typed per-event schemas\n  (`WebhookEventConfirmed/Cancelled/Modified`) replace the untyped `data` bag so\n  receivers can codegen; the replay check now uses the SIGNED body `timestamp`/`id`,\n  not the unsigned headers. Typed the `/rate-rules` cancellation `penalty` (oneOf)\n  and `baseInsurance` shapes; clarified the Bearer-token source; flagged\n  `Office.timezone` as send-always; `Error.code` documented as an open set.\n- **1.3.1** — Published a downloadable, zero-dependency conformance smoke-test at\n  `/api/public/provider-api-devkit` (validate your endpoint without our repo) and\n  made the no-repo path explicit (`Connect & verify` is the authoritative validator).\n- **1.3.0** — Documented the full request lifecycle + worked examples on every\n  endpoint; surfaced the booking fields Bota already sends (`expectedTotalCents`,\n  `pickupTime`/`dropoffTime`, `driverAge`) and the exact reprice retry contract;\n  added `currency` to `BookingDetail`; defined timezone, price composition, and\n  on-request rules; added webhook event-body schemas + registration; added curl\n  walkthrough and Node/PHP reference handlers.\n- **1.2.0** — Required `Idempotency-Key` on `/bookings`; documented webhook signing\n  (`X-Bota-Signature` + `X-Bota-Timestamp`); added `Office.address/phone`.\n- **1.1.0** — Documented `/concepts` + `/rate-rules`; added `Error` envelope,\n  one-way `returnOfficeId`, `customer.documentId/documentTypeId`,\n  `promoCode`/`locale` on `/availability`, and the reprice flow. `currency`\n  validated as ISO-4217.\n- **1.0.0** — Initial contract (7 endpoints).","contact":{"name":"Bota Engineering","url":"https://bota-chat.com"}},"servers":[{"url":"{baseUrl}","description":"Your RMS API base URL","variables":{"baseUrl":{"default":"https://api.your-rms.com/bota","description":"The base URL you configure in Bota settings"}}}],"security":[{"BearerAuth":[]}],"paths":{"/health":{"get":{"operationId":"healthCheck","summary":"Health check","description":"Returns the health status of your API. Called during connection setup to verify the URL and API key are correct.","tags":["Health"],"responses":{"200":{"description":"API is healthy","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","example":true}}},"example":{"ok":true}}}}}}},"/offices":{"get":{"operationId":"listOffices","summary":"List offices","description":"Returns all rental offices/stations where vehicles can be picked up or returned. Return the complete set — there is no pagination.","tags":["Catalog"],"responses":{"200":{"description":"List of offices","content":{"application/json":{"schema":{"type":"object","required":["offices"],"properties":{"offices":{"type":"array","items":{"$ref":"#/components/schemas/Office"}}}},"example":{"offices":[{"id":"office-pmi","code":"PMI","name":"Palma Airport","isMainOffice":true,"city":"Palma de Mallorca","address":"Aeropuerto de Palma, T1","phone":"+34971000000","timezone":"Europe/Madrid","latitude":39.5517,"longitude":2.7388}]}}}}}}},"/availability":{"post":{"operationId":"searchAvailability","summary":"Search availability","description":"Search for available vehicles at an office and date range. Returns offers with pricing. Carry the chosen offer’s groupId + tariffId + rateRef into POST /bookings.","tags":["Availability"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["officeId","pickupDate","dropoffDate"],"properties":{"officeId":{"type":"string","description":"Office ID from /offices"},"returnOfficeId":{"type":"string","description":"One-way return office. When present, quote the one-way surcharge into the offer price. Omit for same-office rentals."},"pickupDate":{"type":"string","example":"2026-07-15","description":"Pickup date (office-local). Time → pickupTime."},"dropoffDate":{"type":"string","example":"2026-07-22","description":"Return date (office-local). Time → dropoffTime."},"pickupTime":{"type":"string","example":"10:00","description":"Local time-of-day (HH:MM) at the pickup office."},"dropoffTime":{"type":"string","example":"10:00","description":"Local time-of-day (HH:MM) at the return office."},"driverAge":{"type":"integer","minimum":18,"maximum":99,"example":30},"promoCode":{"type":"string","description":"Optional promotional / corporate code."},"locale":{"type":"string","example":"es-ES","description":"BCP-47 customer locale. Localise vehicle/office/extra text when supported; ignore otherwise. Currency is independent of locale."}}},"example":{"officeId":"office-pmi","pickupDate":"2026-07-15","dropoffDate":"2026-07-22","pickupTime":"10:00","dropoffTime":"10:00","driverAge":30,"locale":"es-ES"}}}},"responses":{"200":{"description":"Available offers","content":{"application/json":{"schema":{"type":"object","required":["offers"],"properties":{"offers":{"type":"array","items":{"$ref":"#/components/schemas/Offer"}},"promoStatus":{"type":"object","nullable":true,"description":"Result of the promo code sent in this request. Omit when no code was sent. Without it the chat cannot acknowledge the code, and a code injected by the operator that your RMS rejected would be announced as if it had applied.","required":["code","valid"],"properties":{"code":{"type":"string","example":"SUMMER10"},"valid":{"type":"boolean"},"percentage":{"type":"number","nullable":true,"description":"Only when valid AND the same % applies to every offer. If it varies per offer, omit it and send discountCents/discountPercent on each offer instead — an aggregate % would be shown as applying to all.","example":10},"failReason":{"type":"string","nullable":true,"description":"Machine-readable reason (\"expired\", \"min_days\"…). Not shown raw: the chat rephrases it."}}}}},"example":{"offers":[{"groupId":"compact","groupName":"Compact (Seat Ibiza or similar)","tariffId":"t1","rateRef":"r1","officeId":"office-pmi","totalPriceCents":25000,"pricePerDayCents":3571,"currency":"EUR","acriss":"CDMR","model":"Seat Ibiza","pax":5,"bags":2,"doors":5}]}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Vehicle no longer available","code":"BOOKING_UNAVAILABLE","details":{"tariffId":"t1"}}}}}}}},"/extras":{"get":{"operationId":"listExtras","summary":"List available extras","description":"Returns add-ons available for a specific tariff (GPS, child seat, insurance, etc.).","tags":["Availability"],"parameters":[{"name":"tariffId","in":"query","required":true,"schema":{"type":"string"},"description":"Tariff ID from an availability offer"}],"responses":{"200":{"description":"Available extras","content":{"application/json":{"schema":{"type":"object","required":["extras"],"properties":{"extras":{"type":"array","items":{"$ref":"#/components/schemas/Extra"}}}},"example":{"extras":[{"id":"gps","code":"GPS","name":"GPS Navigator","pricePerDayCents":500,"priceTotalCents":3500,"maxQty":1,"mandatory":false,"concept":"gps"},{"id":"cov-medium","code":"SCDW","name":"Cobertura Media","pricePerDayCents":800,"priceTotalCents":5600,"mandatory":false,"insuranceKind":"SCDW","deductibleCents":30000,"depositCents":30000,"depositReductionCents":30000,"coverageExclusive":true,"concept":"insurance_upgrade"},{"id":"cov-premium","code":"SCDW0","name":"Cobertura Premium","pricePerDayCents":1200,"priceTotalCents":8400,"mandatory":false,"insuranceKind":"SCDW","deductibleCents":0,"depositCents":0,"depositReductionCents":60000,"coverageExclusive":true,"concept":"insurance_upgrade"}]}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Vehicle no longer available","code":"BOOKING_UNAVAILABLE","details":{"tariffId":"t1"}}}}}}}},"/concepts":{"get":{"operationId":"listConcepts","summary":"List rate concepts (schema v2, optional)","description":"Three-bucket concept payload for a tariff: `sellable` (= /extras), `included` (already in the rate), `required` (mandatory at pickup, charged separately). Preferred over /extras when you can model included/required concepts. Optional — return 404 if unimplemented and Bota falls back to /extras.","tags":["Availability"],"parameters":[{"name":"tariffId","in":"query","required":true,"schema":{"type":"string"},"description":"Tariff ID from an availability offer"}],"responses":{"200":{"description":"Concept buckets","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConceptsResponse"},"example":{"sellable":[{"id":"gps","code":"GPS","name":"GPS Navigator","pricePerDayCents":500,"priceTotalCents":3500,"maxQty":1,"mandatory":false,"concept":"gps"}],"included":[{"name":"Additional driver","concept":"additional_driver"}],"required":[{"name":"Airport fee","priceTotalCents":1200,"currency":"EUR","concept":"airport_fee"}]}}}},"404":{"description":"Endpoint not implemented — Bota falls back to /extras"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Vehicle no longer available","code":"BOOKING_UNAVAILABLE","details":{"tariffId":"t1"}}}}}}}},"/rate-rules":{"post":{"operationId":"getRateRules","summary":"Bulk rate rules (preferred)","description":"Bulk rate rules for one or more tariffs — the canonical answer to OTA_VehRateRule. Preferred over per-tariff /concepts: a single round-trip returns conditions + insurance + included/required concepts + sellable extras. Optional — if unimplemented (404/501), Bota falls back to GET /concepts?tariffId= per tariff and flags diagnostics.fallbackUsed. Without `rentalContext`, return tariff-level base data; deposits/young-driver fees that depend on dates or age are then best-effort and may be refined at booking.","tags":["Availability"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tariffIds"],"properties":{"tariffIds":{"type":"array","items":{"type":"string"},"description":"Tariff IDs to fetch rate rules for."},"locale":{"type":"string","example":"es-ES"},"rentalContext":{"type":"object","description":"Optional rental window so you can compute date-bracketed deposits / young-driver fees. Without it, return best-effort base data.","properties":{"pickupDate":{"type":"string","example":"2026-07-15"},"dropoffDate":{"type":"string","example":"2026-07-22"},"driverAge":{"type":"integer","example":30}}}}},"example":{"tariffIds":["t1"],"locale":"es-ES","rentalContext":{"pickupDate":"2026-07-15","dropoffDate":"2026-07-22","driverAge":30}}}}},"responses":{"200":{"description":"Rate rules keyed by tariffId","content":{"application/json":{"schema":{"type":"object","required":["rateRules"],"properties":{"rateRules":{"type":"object","description":"Map of tariffId → RateRules. An empty object is valid (no rate rules to share).","additionalProperties":{"$ref":"#/components/schemas/RateRules"}}}},"example":{"rateRules":{"t1":{"tariffId":"t1","tariffConditions":{"unlimitedKm":true,"minDriverAge":21,"fuelPolicy":"full_to_full","depositCents":60000,"depositCurrency":"EUR"},"includedConcepts":[{"name":"Additional driver","concept":"additional_driver"}],"requiredConcepts":[],"sellableExtras":[{"id":"gps","code":"GPS","name":"GPS Navigator","pricePerDayCents":500,"priceTotalCents":3500,"maxQty":1,"mandatory":false,"concept":"gps"},{"id":"cov-medium","code":"SCDW","name":"Cobertura Media","pricePerDayCents":800,"priceTotalCents":5600,"mandatory":false,"insuranceKind":"SCDW","deductibleCents":30000,"depositCents":30000,"depositReductionCents":30000,"coverageExclusive":true,"concept":"insurance_upgrade"},{"id":"cov-premium","code":"SCDW0","name":"Cobertura Premium","pricePerDayCents":1200,"priceTotalCents":8400,"mandatory":false,"insuranceKind":"SCDW","deductibleCents":0,"depositCents":0,"depositReductionCents":60000,"coverageExclusive":true,"concept":"insurance_upgrade"}],"diagnostics":{"sellableExtrasSource":"tariff-scoped"}}}}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Vehicle no longer available","code":"BOOKING_UNAVAILABLE","details":{"tariffId":"t1"}}}}}}}},"/bookings":{"post":{"operationId":"createBooking","summary":"Create booking","description":"Creates a reservation. Returns the booking id and confirmation status.\n\n**Idempotency (required):** every request carries an `Idempotency-Key` header, stable across retries of the same attempt. You MUST dedupe on it: return the original result for a repeated key instead of creating a second reservation. A network blip + our retry must never double-book.\n\n**Price:** echo the accepted price in `expectedTotalCents`; return `reprice_required` with a new `totalCents` if it moved (see the reprice flow in the introduction). Confirm **synchronously** even for on-request offices — there is no async pending status.","tags":["Bookings"],"parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"},"description":"Dedupe key. Return the original booking for a repeated key."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBookingRequest"},"example":{"officeId":"office-pmi","pickupDate":"2026-07-15","dropoffDate":"2026-07-22","pickupTime":"10:00","dropoffTime":"10:00","driverAge":30,"groupId":"compact","tariffId":"t1","rateRef":"r1","customer":{"givenName":"María","surname":"García","email":"maria@example.com","phone":"+34612345678"},"reservation":{"notes":"Customer arrives late, please hold the car.","flightNumber":"IB3501"},"selectedExtras":[{"id":"gps","code":"GPS","name":"GPS Navigator","qty":1,"priceCents":3500}],"expectedTotalCents":25000}}}},"responses":{"201":{"description":"Booking confirmed, or reprice required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingResult"},"examples":{"confirmed":{"summary":"Confirmed","value":{"bookingId":"BK-1","status":"confirmed","totalCents":28500,"currency":"EUR"}},"reprice_required":{"summary":"Price moved — re-confirm with expectedTotalCents","value":{"bookingId":"","status":"reprice_required","totalCents":29900,"currency":"EUR"}}}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Vehicle no longer available","code":"BOOKING_UNAVAILABLE","details":{"tariffId":"t1"}}}}}}}},"/bookings/{bookingId}":{"get":{"operationId":"getBooking","summary":"Get booking detail","description":"Returns the current details of a booking.","tags":["Bookings"],"parameters":[{"name":"bookingId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Booking detail","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingDetail"},"example":{"bookingId":"BK-1","status":"confirmed","pickupDate":"2026-07-15","returnDate":"2026-07-22","officeName":"Palma Airport","vehicleName":"Seat Ibiza","customerName":"María García","totalCents":28500,"depositCents":50000,"currency":"EUR"}}}},"404":{"description":"Booking not found"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Vehicle no longer available","code":"BOOKING_UNAVAILABLE","details":{"tariffId":"t1"}}}}}}}},"/bookings/{bookingId}/cancel":{"post":{"operationId":"cancelBooking","summary":"Cancel booking","description":"Cancels an existing booking. Apply any cancellation penalty from your tariff’s cancellationPolicy on your side; this response only echoes the cancelled state.","tags":["Bookings"],"parameters":[{"name":"bookingId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","example":"Customer requested cancellation"}}}}}},"responses":{"200":{"description":"Booking cancelled","content":{"application/json":{"schema":{"type":"object","required":["bookingId","status"],"properties":{"bookingId":{"type":"string"},"status":{"type":"string","enum":["cancelled"]}}},"example":{"bookingId":"BK-1","status":"cancelled"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Vehicle no longer available","code":"BOOKING_UNAVAILABLE","details":{"tariffId":"t1"}}}}}}}},"/bookings/{bookingId}/modify":{"post":{"operationId":"modifyBooking","summary":"Modify booking dates","description":"Modifies the pickup and/or dropoff dates of an existing booking and returns the new total. If the new dates are unavailable, fail with the Error envelope (e.g. 409 BOOKING_UNAVAILABLE) rather than silently keeping the old dates.","tags":["Bookings"],"parameters":[{"name":"bookingId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"newPickupDate":{"type":"string","example":"2026-07-16"},"newDropoffDate":{"type":"string","example":"2026-07-23"}}}}}},"responses":{"200":{"description":"Booking modified","content":{"application/json":{"schema":{"type":"object","required":["bookingId","status"],"properties":{"bookingId":{"type":"string"},"status":{"type":"string","enum":["modified"]},"newTotalCents":{"type":"integer","description":"Updated total price in cents"}}},"example":{"bookingId":"BK-1","status":"modified","newTotalCents":26000}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Vehicle no longer available","code":"BOOKING_UNAVAILABLE","details":{"tariffId":"t1"}}}}}}}}},"webhooks":{"booking.confirmed":{"post":{"summary":"Booking confirmed","description":"Sent after a booking is confirmed. `data` carries Bota’s confirmationCode + distribution channel (attribution your RMS call lacks).","tags":["Webhooks"],"parameters":[{"name":"X-Bota-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Event name (advisory mirror of the body `event`)."},{"name":"X-Bota-Delivery-Id","in":"header","required":true,"schema":{"type":"string","format":"uuid"},"description":"Stable per-event id (mirror of the signed body `id`). Dedupe on it — retries reuse it."},{"name":"X-Bota-Timestamp","in":"header","required":true,"schema":{"type":"string","format":"date-time"},"description":"Advisory mirror of the signed body `timestamp`. For the replay check use the body value (it is signed); reject older than ~5 min."},{"name":"X-Bota-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"HMAC-SHA256(rawBody, webhookSecret) as hex. Verify in constant time, THEN trust the body fields."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEventConfirmed"},"example":{"id":"7f3c1e9a-2b4d-4e6f-8a1b-0c2d4e6f8a1b","event":"booking.confirmed","timestamp":"2026-07-10T09:30:00.000Z","data":{"confirmationCode":"BOTA-7F3C1E","supplierConfirmationId":"BK-1","bookingId":"BK-1","distributorCode":"BOTA","distributorChannel":"BOTA_WHATSAPP","connectionType":"REST","pickupDateTime":"2026-07-15T10:00:00.000Z","returnDateTime":"2026-07-22T10:00:00.000Z","pickupLocationCode":"PMI","vehicleSippCode":"CDMR","totalCents":28500,"currencyCode":"EUR","status":"RESERVED"}}}}},"responses":{"2XX":{"description":"Return any 2xx to acknowledge. On non-2xx or timeout Bota retries with backoff."}}}},"booking.cancelled":{"post":{"summary":"Booking cancelled","description":"Sent after a booking is cancelled via Bota.","tags":["Webhooks"],"parameters":[{"name":"X-Bota-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Event name (advisory mirror of the body `event`)."},{"name":"X-Bota-Delivery-Id","in":"header","required":true,"schema":{"type":"string","format":"uuid"},"description":"Stable per-event id (mirror of the signed body `id`). Dedupe on it — retries reuse it."},{"name":"X-Bota-Timestamp","in":"header","required":true,"schema":{"type":"string","format":"date-time"},"description":"Advisory mirror of the signed body `timestamp`. For the replay check use the body value (it is signed); reject older than ~5 min."},{"name":"X-Bota-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"HMAC-SHA256(rawBody, webhookSecret) as hex. Verify in constant time, THEN trust the body fields."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEventCancelled"},"example":{"id":"1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d","event":"booking.cancelled","timestamp":"2026-07-11T12:00:00.000Z","data":{"confirmationCode":"BOTA-7F3C1E","supplierConfirmationId":"BK-1","distributorCode":"BOTA","distributorChannel":"BOTA_WHATSAPP","cancelledAt":"2026-07-11T12:00:00.000Z","status":"CANCELLED"}}}}},"responses":{"2XX":{"description":"Return any 2xx to acknowledge. On non-2xx or timeout Bota retries with backoff."}}}},"booking.modified":{"post":{"summary":"Booking modified","description":"Sent after a booking’s dates/total change via Bota.","tags":["Webhooks"],"parameters":[{"name":"X-Bota-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Event name (advisory mirror of the body `event`)."},{"name":"X-Bota-Delivery-Id","in":"header","required":true,"schema":{"type":"string","format":"uuid"},"description":"Stable per-event id (mirror of the signed body `id`). Dedupe on it — retries reuse it."},{"name":"X-Bota-Timestamp","in":"header","required":true,"schema":{"type":"string","format":"date-time"},"description":"Advisory mirror of the signed body `timestamp`. For the replay check use the body value (it is signed); reject older than ~5 min."},{"name":"X-Bota-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"HMAC-SHA256(rawBody, webhookSecret) as hex. Verify in constant time, THEN trust the body fields."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEventModified"},"example":{"id":"2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e","event":"booking.modified","timestamp":"2026-07-12T08:15:00.000Z","data":{"confirmationCode":"BOTA-7F3C1E","supplierConfirmationId":"BK-1","distributorCode":"BOTA","distributorChannel":"BOTA_WHATSAPP","newPickupDateTime":"2026-07-16T10:00:00.000Z","newReturnDateTime":"2026-07-23T10:00:00.000Z","newTotalCents":26000,"status":"MODIFIED"}}}}},"responses":{"2XX":{"description":"Return any 2xx to acknowledge. On non-2xx or timeout Bota retries with backoff."}}}}},"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"The token you generate in the Bota settings panel (or paste your own). Bota presents it on every request; your endpoint must require exactly that value."}},"schemas":{"Office":{"type":"object","required":["id","code","name"],"properties":{"id":{"type":"string","example":"office-001"},"code":{"type":"string","example":"PMI","description":"IATA code or internal code"},"name":{"type":"string","example":"Palma Airport"},"isMainOffice":{"type":"boolean","default":false},"city":{"type":"string","example":"Palma de Mallorca"},"address":{"type":"string","nullable":true,"example":"Aeropuerto de Palma, T1"},"phone":{"type":"string","nullable":true,"example":"+34971000000"},"timezone":{"type":"string","example":"Europe/Madrid","description":"IANA timezone of the station. STRONGLY recommended — always send it. It is the only timezone input for pickup/return times; when absent Bota cannot convert wall-clock times and may mis-time slots on cross-zone or one-way rentals (off-by-hours at the counter)."},"latitude":{"type":"number","example":39.5517},"longitude":{"type":"number","example":2.7388},"allowCollectionAfterHours":{"type":"boolean","nullable":true,"description":"Office accepts pickups outside its declared schedule (typically with a surcharge billed at the counter). Optional; absent = default behaviour (reject out-of-schedule slots)."},"isOnRequestOffice":{"type":"boolean","nullable":true,"description":"Office is bookable on-request only — availability shown by the RMS is provisional. Optional; absent = standard immediate-confirmation office."},"officeHours":{"type":"string","nullable":true,"example":"Mon-Fri 09:00-13:00 / 16:00-20:00 · Sun closed","description":"Human-readable weekly hours shown to the customer. Optional."},"openingHours":{"type":"object","nullable":true,"description":"Structured weekly schedule. `regular` maps weekday keys (monday..sunday) to `{ closed, ranges: [{start:\"HH:MM\", end:\"HH:MM\"}] }` — MULTIPLE ranges per day model split schedules (midday close). `exceptions` lists specific closed dates (\"YYYY-MM-DD\", e.g. holidays). Strongly recommended: it powers out-of-hours explanations, next-open-time suggestions and non-selectable closed days/hours in the customer pickers.","properties":{"regular":{"type":"object","additionalProperties":{"type":"object","properties":{"closed":{"type":"boolean"},"ranges":{"type":"array","items":{"type":"object","properties":{"start":{"type":"string","example":"09:00"},"end":{"type":"string","example":"13:00"}}}}}}},"exceptions":{"type":"array","items":{"type":"string","example":"2026-12-25"}}}}}},"Offer":{"type":"object","required":["groupId","groupName","tariffId","totalPriceCents","pricePerDayCents","currency"],"properties":{"groupId":{"type":"string","example":"compact"},"groupName":{"type":"string","example":"Compact (Seat Ibiza or similar)"},"tariffId":{"type":"string","example":"tariff-summer-2026"},"rateRef":{"type":"string","description":"Opaque reference for booking"},"officeId":{"type":"string"},"totalPriceCents":{"type":"integer","example":25000,"description":"Total price in cents (€250.00)"},"pricePerDayCents":{"type":"integer","example":3571,"description":"Daily price in cents"},"discountCents":{"type":"integer","nullable":true,"example":2500,"description":"Discount ALREADY subtracted from totalPriceCents, for THIS offer. Drives the before/after strikethrough in the chat. Send it only on offers the discount really applied to — a value on a non-eligible offer would paint a fake saving."},"discountPercent":{"type":"number","nullable":true,"example":10,"description":"Alternative to discountCents when your RMS reasons in %. Same per-offer rule. If you send both, discountCents wins (exact, no rounding)."},"currency":{"type":"string","example":"EUR"},"acriss":{"type":"string","example":"CDMR","description":"ACRISS/SIPP vehicle code"},"model":{"type":"string","example":"Seat Ibiza"},"pax":{"type":"integer","example":5},"bags":{"type":"integer","example":2},"doors":{"type":"integer","example":5},"vehicleType":{"type":"string","nullable":true,"description":"Broad taxonomy: \"car\", \"van\", \"motorbike\", \"truck\", \"bus\", \"scooter\", \"bicycle\", \"rv\", \"special\". Optional — adapter derives it server-side from ACRISS position 2 when absent."},"category":{"type":"string","nullable":true,"description":"ACRISS segment (\"economy\", \"compact\", \"intermediate\", \"fullsize\", \"premium\", \"luxury\", \"SUV\"…). Optional — adapter derives it server-side from ACRISS position 1 when absent."},"baseInsurance":{"type":"object","nullable":true,"description":"Per-coverage base insurance projection. No server-side fallback — chat carousel hides the chip when omitted.","properties":{"kind":{"type":"string","example":"CDW+TP"},"cdw":{"type":"object","properties":{"deductibleCents":{"type":"integer","example":50000}}},"theft":{"type":"object","properties":{"deductibleCents":{"type":"integer","example":80000}}},"deductibleCents":{"type":"integer","nullable":true,"description":"Legacy aggregate (smallest excess)."},"depositReductionCents":{"type":"integer","nullable":true}}}}},"Extra":{"type":"object","required":["id","code","name"],"properties":{"id":{"type":"string","example":"extra-gps"},"code":{"type":"string","example":"GPS"},"name":{"type":"string","example":"GPS Navigator"},"pricePerDayCents":{"type":"integer","example":500,"description":"Price per day in cents"},"priceTotalCents":{"type":"integer","example":3500,"description":"Total price for the rental period"},"maxQty":{"type":"integer","example":1},"description":{"type":"string"},"mandatory":{"type":"boolean","default":false},"isIncluded":{"type":"boolean","default":false,"description":"Marks the extra as already part of the rate (no additional cost). Optional; if set the chat lifts it into the \"included\" bucket instead of the customer-buyable list."},"insuranceKind":{"type":"string","nullable":true,"description":"Spec 1.5.0 — marks the extra as an INSURANCE/coverage and names its kind. OPEN set; canonical codes: CDW, SCDW, TP, TPL, PAI, RCA. Declare it explicitly — Bota never infers it from the name. Any extra carrying insuranceKind/deductibleCents/coverageExclusive is offered in the \"Insurance\" section of the chat, with tiers as a single-choice radio when they form a monotonic price→cover ladder."},"deductibleCents":{"type":"integer","nullable":true,"description":"Damage excess (franquicia) the customer keeps WITH this coverage, minor units. Bota upsells tiers comparing this against the base insurance excess."},"depositCents":{"type":"integer","nullable":true,"description":"Deposit hold WITH this coverage, minor units. Shown on the booking card when the tier is selected."},"depositReductionCents":{"type":"integer","nullable":true,"description":"How much this coverage shaves off the base deposit hold, minor units. Optional — when absent and both tariffConditions.depositCents and depositCents are known, Bota derives it."},"coverageExclusive":{"type":"boolean","description":"Hint that this coverage belongs to a set of mutually-exclusive tiers (pick ONE — radio UI). Bota still applies a structural safety gate: tiers that do not form a monotonic price→cover ladder degrade to independent checkboxes."},"preselected":{"type":"boolean","description":"The RMS auto-applies this coverage unless the customer picks another exclusive tier. Bota includes it in the quoted total so the amount shown matches the amount charged."},"concept":{"type":"string","nullable":true,"description":"Spec 1.5.0 — normalized concept code for cross-RMS attribution. OPEN set; canonical codes: airport_fee, young_driver_fee, additional_driver, winter_equipment, cross_border, one_way_fee, after_hours, cleaning, fuel_service, gps, child_seat, booster_seat, baby_seat, wifi, toll_device, insurance_upgrade, other. Unknown values are accepted and treated as `other`."}}},"ConceptsResponse":{"type":"object","description":"Schema v2 — three-bucket payload for GET /concepts?tariffId=. Replaces /extras?tariffId= for integrators that want the agent to verbalise included/required concepts before listing extras. Endpoint is optional; missing endpoint returns 404 and the chat falls back to /extras with empty included/required.","properties":{"sellable":{"type":"array","items":{"$ref":"#/components/schemas/Extra"},"description":"Same shape as /extras?tariffId= response."},"included":{"type":"array","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string","example":"Additional driver"},"description":{"type":"string","nullable":true},"concept":{"type":"string","nullable":true,"example":"additional_driver","description":"Normalized concept code (see Extra.concept)."}}},"description":"Concepts already part of the rate — chat says \"your rate includes …\" before listing extras."},"required":{"type":"array","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string","example":"Airport fee"},"description":{"type":"string","nullable":true},"priceTotalCents":{"type":"integer","nullable":true,"example":1200},"currency":{"type":"string","nullable":true,"example":"EUR"},"concept":{"type":"string","nullable":true,"example":"airport_fee","description":"Normalized concept code (see Extra.concept)."}}},"description":"Mandatory at pickup, NOT included — chat discloses the price before confirmation so the customer is not surprised at the counter."}}},"CreateBookingRequest":{"type":"object","required":["officeId","pickupDate","dropoffDate","groupId","tariffId","customer"],"properties":{"officeId":{"type":"string","example":"office-pmi"},"returnOfficeId":{"type":"string","description":"One-way return office. MUST match the office sent on the /availability quote so the one-way surcharge is honoured. Omit for same-office rentals."},"pickupDate":{"type":"string","example":"2026-07-15"},"dropoffDate":{"type":"string","example":"2026-07-22"},"pickupTime":{"type":"string","example":"10:00","description":"Local time-of-day (HH:MM) at the pickup office."},"dropoffTime":{"type":"string","example":"10:00","description":"Local time-of-day (HH:MM) at the return office."},"driverAge":{"type":"integer","minimum":18,"maximum":99,"example":30,"description":"Main driver age — re-sent so young-driver fees match the quote."},"groupId":{"type":"string","example":"compact"},"tariffId":{"type":"string","example":"t1"},"rateRef":{"type":"string","example":"r1","description":"Opaque price/availability lock from the chosen offer. Echo it to honour the quoted price; omit (or a stale value) to re-price — which may yield `reprice_required`."},"expectedTotalCents":{"type":"integer","example":25000,"description":"The price the customer accepted: the chosen offer total, or the `totalCents` from a prior `reprice_required`. Confirm when your live price matches it; return `reprice_required` if it moved. This is how a retry signals \"customer accepted the new price\" (see the reprice flow in the introduction)."},"customer":{"type":"object","required":["givenName","surname","email"],"properties":{"givenName":{"type":"string","example":"María"},"surname":{"type":"string","example":"García"},"email":{"type":"string","format":"email","example":"maria@example.com"},"phone":{"type":"string","example":"+34612345678"},"documentId":{"type":"string","nullable":true,"description":"National ID / passport number. Required by some RMS (e.g. Rently) to create the booking. Optional otherwise."},"documentTypeId":{"type":"integer","nullable":true,"description":"RMS-specific document-type id. Optional; when omitted the RMS may fall back to its configured default."}}},"reservation":{"type":"object","description":"Optional free-text booking metadata Bota collected from the customer. Persist each field in your RMS native column; if you have no native flight field, append the flight number to the notes/comments instead of dropping it. Omitted ⇒ no metadata.","properties":{"notes":{"type":"string","example":"Customer arrives late, please hold the car.","description":"Free-text note for the reservation. Map to your native notes/comments field."},"flightNumber":{"type":"string","example":"IB3501","description":"Arrival flight/train number. Map to your native flight field; degrade to the notes field if you have none."}}},"selectedExtras":{"type":"array","description":"Extras the customer added in the chat. A coverage tier picked from `insuranceKind`-tagged extras (spec 1.5.0) arrives here as one more line with its `code` — there is no separate insurance field on the booking request.","items":{"type":"object","required":["id","code","name","qty","priceCents"],"properties":{"id":{"type":"string","example":"gps"},"code":{"type":"string","example":"GPS"},"name":{"type":"string","example":"GPS Navigator"},"qty":{"type":"integer","minimum":1,"example":1},"priceCents":{"type":"integer","example":3500,"description":"Accepted price for this line for the whole rental (typically the extra’s priceTotalCents). Your server is authoritative for the final total."}}}},"promoCode":{"type":"string"},"ratePlan":{"type":"object","description":"2.6.x — optional NEGOTIATED rate plan (corporate/insurer contract), distinct from the `promoCode` discount coupon. `rateCode` is the requestable rate identity; `account.type=insurer|corporate` signals a B2B-billed (prepaid-settled) booking.","required":["rateCode"],"properties":{"rateCode":{"type":"string"},"ratePlanId":{"type":"string"},"account":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","enum":["corporate","insurer","broker","promo"]},"label":{"type":"string"}}}}}}},"BookingResult":{"type":"object","required":["bookingId","status","totalCents","currency"],"description":"Result of a confirmation attempt. Two synchronous statuses (no async pending):\n\n- `confirmed`: the booking is final. `bookingId` is the reservation reference shown to the customer.\n- `reprice_required`: **Bota extension over OTA**. The booking is NOT created because the price the customer accepted is no longer valid. Surface the new `totalCents` and re-call POST /bookings with `expectedTotalCents` set to it once the customer accepts. Other OTA resellers fail with an error and force a new search; we emit this so the chat recovers with a single re-prompt.","properties":{"bookingId":{"type":"string","example":"BK-1","description":"Reservation reference. Empty on `reprice_required`."},"status":{"type":"string","enum":["confirmed","reprice_required"]},"totalCents":{"type":"integer","example":25000,"description":"All-in rental price (extras + required concepts + one-way surcharge; deposit excluded). On `reprice_required`, the **new** price to re-confirm."},"currency":{"type":"string","example":"EUR"},"settlement":{"type":"object","description":"2.6.x — optional settlement breakdown. `prepaid[]` classifies each prepaid portion as `settled` (already paid by a B2B/insurer account) or `pending` (collected online before pickup via a link); the rest is `dueAtCounterCents`. Invariant: Σ prepaid.amountCents + dueAtCounterCents == totalCents. Partners that don't model it omit it.","properties":{"totalCents":{"type":"integer"},"dueAtCounterCents":{"type":"integer"},"depositCents":{"type":"integer"},"prepaid":{"type":"array","items":{"type":"object","properties":{"amountCents":{"type":"integer"},"status":{"type":"string","enum":["settled","pending"]},"method":{"type":"string","enum":["b2b_account","payment_link"]}}}}}}}},"BookingDetail":{"type":"object","required":["bookingId","status","totalCents"],"properties":{"bookingId":{"type":"string","example":"BK-1"},"status":{"type":"string","example":"confirmed","description":"Lifecycle state. Canonical values: `confirmed`, `cancelled`, `modified` (free-form strings are tolerated but map to these for the chat)."},"pickupDate":{"type":"string","format":"date-time"},"returnDate":{"type":"string","format":"date-time"},"officeName":{"type":"string"},"vehicleName":{"type":"string"},"customerName":{"type":"string"},"totalCents":{"type":"integer","example":28500},"depositCents":{"type":"integer","description":"Refundable deposit hold — separate from totalCents."},"currency":{"type":"string","nullable":true,"example":"EUR","description":"ISO-4217 currency of totalCents/depositCents. Echo the currency you confirmed with so the read-back is unambiguous."},"settlement":{"type":"object","description":"2.6.x — optional settlement breakdown (same shape as on BookingResult). `prepaid[]` classifies each prepaid portion as `settled` (already paid by a B2B/insurer account) or `pending` (collected online before pickup via a link); the rest is `dueAtCounterCents`. Invariant: Σ prepaid.amountCents + dueAtCounterCents == totalCents. Partners that don't model it omit it.","properties":{"totalCents":{"type":"integer"},"dueAtCounterCents":{"type":"integer"},"depositCents":{"type":"integer"},"prepaid":{"type":"array","items":{"type":"object","properties":{"amountCents":{"type":"integer"},"status":{"type":"string","enum":["settled","pending"]},"method":{"type":"string","enum":["b2b_account","payment_link"]}}}}}}}},"RateRules":{"type":"object","required":["tariffId"],"description":"Per-tariff rate rules — the canonical answer to POST /rate-rules (OTA_VehRateRule equivalent). Integrators that do not implement /rate-rules can serve GET /concepts?tariffId= per tariff instead; Bota then fills only the concept buckets and marks diagnostics.fallbackUsed.","properties":{"tariffId":{"type":"string"},"tariffConditions":{"type":"object","description":"Structured fields are preferred; the legacy free-form strings (kmPolicy, deposit, …) are accepted for back-compat.","properties":{"unlimitedKm":{"type":"boolean"},"kmIncludedPerDay":{"type":"integer"},"kmIncludedTotal":{"type":"integer"},"kmExtraCostCents":{"type":"integer"},"minDriverAge":{"type":"integer"},"cancellationPolicy":{"type":"object","nullable":true,"properties":{"freeUntilHoursBefore":{"type":"integer","nullable":true},"penalty":{"nullable":true,"description":"Cancellation penalty, discriminated by `kind`.","discriminator":{"propertyName":"kind"},"oneOf":[{"type":"object","required":["kind"],"properties":{"kind":{"type":"string","enum":["none"]}}},{"type":"object","required":["kind","amountCents","currency"],"properties":{"kind":{"type":"string","enum":["flat"]},"amountCents":{"type":"integer","minimum":0},"currency":{"type":"string","example":"EUR"}}},{"type":"object","required":["kind","percent"],"properties":{"kind":{"type":"string","enum":["pct"]},"percent":{"type":"number","minimum":0,"maximum":100}}},{"type":"object","required":["kind","nights"],"properties":{"kind":{"type":"string","enum":["nights"]},"nights":{"type":"integer","minimum":1}}}]}}},"fuelPolicy":{"type":"string","example":"full_to_full","description":"Fuel policy. OPEN set; canonical codes `full_to_full`, `full_to_empty`, `same_to_same` are localized into the customer language on the confirmation card — any other string is displayed as-is. Prefer the canonical codes."},"deposit":{"type":"string","description":"Legacy free-form deposit text. Prefer `depositCents` + `depositCurrency` (spec 1.5.0)."},"depositCents":{"type":"integer","nullable":true,"example":60000,"description":"Spec 1.5.0 — pre-booking deposit hold in minor units. Bota verbalises it at the extras step (\"temporary card hold of €X\") and uses it to derive coverage-tier deposit reductions."},"depositCurrency":{"type":"string","nullable":true,"example":"EUR","description":"ISO-4217 currency of depositCents."},"insurance":{"type":"string"},"taxInfo":{"type":"string"}}},"baseInsurance":{"type":"object","nullable":true,"description":"Per-coverage base insurance projection (same shape as Offer.baseInsurance).","properties":{"kind":{"type":"string","example":"CDW+TP"},"cdw":{"type":"object","properties":{"deductibleCents":{"type":"integer","example":50000}}},"theft":{"type":"object","properties":{"deductibleCents":{"type":"integer","example":80000}}},"deductibleCents":{"type":"integer","nullable":true,"description":"Legacy aggregate (smallest excess)."},"depositReductionCents":{"type":"integer","nullable":true}}},"includedConcepts":{"type":"array","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"description":{"type":"string","nullable":true},"concept":{"type":"string","nullable":true,"description":"Normalized concept code (see Extra.concept)."}}}},"requiredConcepts":{"type":"array","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"description":{"type":"string","nullable":true},"priceTotalCents":{"type":"integer","nullable":true},"currency":{"type":"string","nullable":true},"concept":{"type":"string","nullable":true,"description":"Normalized concept code (see Extra.concept)."}}}},"sellableExtras":{"type":"array","items":{"$ref":"#/components/schemas/Extra"},"description":"Spec 1.5.0 — sellable extras may carry the coverage-tier fields (insuranceKind, deductibleCents, depositCents, depositReductionCents, coverageExclusive, preselected) so Bota can sell excess-reduction levels as structured tiers. See Extra."},"diagnostics":{"type":"object","description":"Reliability hints Bota surfaces downstream. `sellableExtrasSource` = where the extras came from; `fallbackUsed`/`notExposed` flag degraded or missing data.","properties":{"sellableExtrasSource":{"type":"string","enum":["tariff-scoped","operator-catalog","kb","none"]},"fallbackUsed":{"type":"array","items":{"type":"string"}},"notExposed":{"type":"array","items":{"type":"string"}}}}}},"Error":{"type":"object","required":["error"],"description":"Standard error envelope. Return it with the matching HTTP status (401/403 auth, 404 not found, 409 conflict, 422 validation, 5xx internal). `code` lets Bota render a precise message; common values: BOOKING_UNAVAILABLE, PRICE_CHANGED, INVENTORY_DEPLETED, VALIDATION_ERROR. `code` is an OPEN set — treat unknown values as a generic error rather than failing.","properties":{"error":{"type":"string","example":"Vehicle no longer available"},"code":{"type":"string","example":"BOOKING_UNAVAILABLE"},"details":{"type":"object","additionalProperties":true}}},"WebhookEvent":{"oneOf":[{"$ref":"#/components/schemas/WebhookEventConfirmed"},{"$ref":"#/components/schemas/WebhookEventCancelled"},{"$ref":"#/components/schemas/WebhookEventModified"}],"discriminator":{"propertyName":"event"},"description":"Outbound webhook body Bota POSTs to your `webhookUrl`. Verify `X-Bota-Signature` = HMAC-SHA256(rawBody, webhookSecret) in constant time, then trust the SIGNED body fields — dedupe on `id`, and reject when `data`/body `timestamp` is older than ~5 min. The `X-Bota-*` headers are advisory mirrors and are not themselves signed."},"WebhookEventConfirmed":{"type":"object","required":["id","event","timestamp","data"],"properties":{"id":{"type":"string","format":"uuid","description":"Stable per-event id, mirrored in `X-Bota-Delivery-Id`. Retries reuse it — dedupe on it so a redelivery is a no-op."},"event":{"type":"string","enum":["booking.confirmed"]},"timestamp":{"type":"string","format":"date-time","description":"ISO-8601 send time. It is part of the SIGNED body — use this (not the X-Bota-Timestamp header) for the ~5-min replay check."},"data":{"type":"object","required":["confirmationCode","supplierConfirmationId","distributorCode","distributorChannel","connectionType","pickupDateTime","returnDateTime","totalCents","currencyCode","status"],"properties":{"confirmationCode":{"type":"string","example":"BOTA-7F3C1E","description":"Bota’s confirmation code — stable attribution join key."},"supplierConfirmationId":{"type":"string","description":"Your bookingId, echoed back."},"bookingId":{"type":"string","nullable":true},"distributorCode":{"type":"string","example":"BOTA"},"distributorChannel":{"type":"string","example":"BOTA_WHATSAPP"},"connectionType":{"type":"string","example":"REST"},"pickupDateTime":{"type":"string","format":"date-time"},"returnDateTime":{"type":"string","format":"date-time"},"pickupLocationCode":{"type":"string","nullable":true},"vehicleSippCode":{"type":"string","nullable":true},"totalCents":{"type":"integer","example":28500},"currencyCode":{"type":"string","example":"EUR"},"status":{"type":"string","enum":["RESERVED"]}}}}},"WebhookEventCancelled":{"type":"object","required":["id","event","timestamp","data"],"properties":{"id":{"type":"string","format":"uuid","description":"Stable per-event id, mirrored in `X-Bota-Delivery-Id`. Retries reuse it — dedupe on it so a redelivery is a no-op."},"event":{"type":"string","enum":["booking.cancelled"]},"timestamp":{"type":"string","format":"date-time","description":"ISO-8601 send time. It is part of the SIGNED body — use this (not the X-Bota-Timestamp header) for the ~5-min replay check."},"data":{"type":"object","required":["confirmationCode","supplierConfirmationId","distributorCode","distributorChannel","cancelledAt","status"],"properties":{"confirmationCode":{"type":"string"},"supplierConfirmationId":{"type":"string"},"distributorCode":{"type":"string"},"distributorChannel":{"type":"string"},"cancelledAt":{"type":"string","format":"date-time"},"status":{"type":"string","enum":["CANCELLED"]}}}}},"WebhookEventModified":{"type":"object","required":["id","event","timestamp","data"],"properties":{"id":{"type":"string","format":"uuid","description":"Stable per-event id, mirrored in `X-Bota-Delivery-Id`. Retries reuse it — dedupe on it so a redelivery is a no-op."},"event":{"type":"string","enum":["booking.modified"]},"timestamp":{"type":"string","format":"date-time","description":"ISO-8601 send time. It is part of the SIGNED body — use this (not the X-Bota-Timestamp header) for the ~5-min replay check."},"data":{"type":"object","required":["confirmationCode","supplierConfirmationId","distributorCode","distributorChannel","status"],"properties":{"confirmationCode":{"type":"string"},"supplierConfirmationId":{"type":"string"},"distributorCode":{"type":"string"},"distributorChannel":{"type":"string"},"newPickupDateTime":{"type":"string","format":"date-time","nullable":true},"newReturnDateTime":{"type":"string","format":"date-time","nullable":true},"newTotalCents":{"type":"integer","nullable":true},"status":{"type":"string","enum":["MODIFIED"]}}}}}}}}