{
  "openapi": "3.1.0",
  "info": {
    "title": "Jet Cab AMS booking API",
    "version": "1.1.0",
    "description": "Two endpoints: a side-effect-free quote and the booking request. Booking creates a stored request and sends email; tours and custom rides also return a checkout URL (our own payment link, paid with iDEAL, card, Apple Pay or Google Pay via SumUp) for a 25% deposit. Availability is never guaranteed by an API response: the driver confirms fixed rides by email, and tours/custom rides are final after payment. Do not call POST /api/book without the customer's explicit approval of the shown quote and acceptance of the terms (https://www.jetcabams.nl/terms.html). Rate limits: 5 booking requests and 30 quotes per client address per 15 minutes; HTTP 429 with Retry-After when exceeded. Every booking requires the signed quoteToken from /api/quote. Quotes expire after 15 minutes; expiry, changed rules or changed journeys return HTTP 409 reviewRequired=true, requiring a fresh quote and renewed customer approval. Custom fares are honored within the validity window, never silently repriced at checkout. Tour pickup is required and at most five tours may be selected. AI agents can also use the remote MCP server at https://www.jetcabams.nl/mcp (tools list_services, get_quote, request_booking, get_booking_status, change_booking, cancel_booking), which runs these same two endpoints. Requests sent without the website (no Origin header) are confirmed by the customer by email before the driver sees them; contact details are checked and limited to 5 requests per email address and per phone number a day. Agent-to-agent (A2A): agent card at https://www.jetcabams.nl/.well-known/agent-card.json, JSON-RPC at https://www.jetcabams.nl/a2a."
  },
  "servers": [
    {
      "url": "https://www.jetcabams.nl"
    }
  ],
  "paths": {
    "/api/quote": {
      "post": {
        "operationId": "quote",
        "summary": "Check rules and price. No booking, email or payment is created; no personal details or terms acceptance required.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteRequest"
              },
              "examples": {
                "fixed": {
                  "summary": "Schiphol transfer, rules/price check only",
                  "value": {
                    "car": "eclass",
                    "date": "2026-10-08",
                    "time": "10:00",
                    "passengers": 2,
                    "type": "fixed",
                    "routeId": "ams-spl",
                    "direction": "to",
                    "address": "Dam 1, 1012 JS Amsterdam"
                  }
                },
                "custom": {
                  "summary": "Custom ride, rules/price check only",
                  "value": {
                    "car": "eclass",
                    "date": "2026-10-08",
                    "time": "10:00",
                    "passengers": 2,
                    "type": "custom",
                    "pickup": "Dam 1, 1012 JS Amsterdam",
                    "dropoff": "Stationsplein 9, 1012 AB Amsterdam"
                  }
                },
                "tour": {
                  "summary": "Private tour with agreed pickup, rules/price check only",
                  "value": {
                    "car": "eclass",
                    "date": "2026-10-08",
                    "time": "10:00",
                    "passengers": 2,
                    "type": "tour",
                    "tourIds": [
                      "amsterdam-city-tour"
                    ],
                    "pickup": "Hotel Okura, Ferdinand Bolstraat 333, Amsterdam"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Eligible under the pricing/time rules (not a guarantee of driver availability).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuoteResponse"
                }
              }
            }
          },
          "400": {
            "description": "Not eligible; error is a machine-readable reason.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited."
          },
          "502": {
            "description": "Custom-ride fare could not be calculated; retry later."
          },
          "503": {
            "description": "quote_unavailable: signing service not configured; no accepted quote is returned."
          }
        }
      }
    },
    "/api/book": {
      "post": {
        "operationId": "book",
        "summary": "Create a booking request (fixed) or start a deposit checkout (tour, custom). Has side effects: stores the request, emails the driver and customer, and may create a payable SumUp checkout.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BookRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "paymentRequired=false: request received, driver confirms by email. paymentRequired=true: send the customer to checkoutUrl to pay the deposit. emailConfirmationRequired=true (requests sent without the website, e.g. by an AI agent or API client): fixed rides are not sent to the driver yet; the customer gets an email with a Confirm link valid for 24 hours and the driver is told only after that click.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BookResponse"
                }
              }
            }
          },
          "400": {
            "description": "Validation or rule error (see error codes). Contact checks: phone (not a real phone number: 8 to 15 digits), email_domain (the email domain cannot receive mail), email_disposable (throwaway inbox services are refused).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "origin: Origin not allowed. blocked: these contact details were blocked by the owner; the customer can use WhatsApp."
          },
          "429": {
            "description": "rate_limit: too many requests from this address (5 per 15 minutes). rate_limit_contact: too many requests with this email address or phone number (5 per 24 hours)."
          },
          "502": {
            "description": "request_failed, payment_failed or custom_estimate_failed. Retrying the identical request with the same bookingId is safe; after editing details use a new bookingId."
          },
          "503": {
            "description": "payment_unavailable or mail_unavailable: service not configured. agent_capacity: today's ceiling for booking requests sent without the website was reached; the customer can book on the website or via WhatsApp."
          },
          "409": {
            "description": "quote_required, quote_invalid, quote_expired, quote_changed or quote_mismatch: reviewRequired=true. No booking/mail/checkout is created. Obtain a new quote and renewed explicit customer approval, then retry with its token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "QuoteRequest": {
        "type": "object",
        "allOf": [
          {
            "$ref": "#/components/schemas/RideRequirements"
          }
        ],
        "required": [
          "type",
          "car",
          "date",
          "time",
          "passengers"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "fixed",
              "custom",
              "tour"
            ]
          },
          "car": {
            "type": "string",
            "enum": [
              "eclass",
              "sclass",
              "vclass"
            ],
            "description": "E-Class max 4, S-Class max 2, V-Class max 7 passengers."
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Local date in Europe/Amsterdam."
          },
          "time": {
            "type": "string",
            "pattern": "^\\d{2}:\\d{2}$",
            "description": "Local time in Europe/Amsterdam, between 04:00 and 20:00."
          },
          "passengers": {
            "type": "integer",
            "minimum": 1,
            "maximum": 7
          },
          "luggageLarge": {
            "type": "integer",
            "minimum": 0,
            "maximum": 8,
            "default": 0,
            "description": "Large check-in suitcases. E-Class and S-Class take at most 2; more needs the V-Class (error code luggage)."
          },
          "luggageSmall": {
            "type": "integer",
            "minimum": 0,
            "maximum": 8,
            "default": 0,
            "description": "Pieces of hand luggage. E-Class and S-Class take at most 2; more needs the V-Class (error code luggage)."
          },
          "returnTrip": {
            "type": "object",
            "description": "Fixed rides only. Books the return leg as well: the opposite direction between the same address and Schiphol, with the same vehicle, passengers and luggage. It is priced as a second ride (plus the name-card fee when meetGreet is set on a return that starts at Schiphol). It must be at least one hour after the first ride, between 04:00 and 20:00, and not on a blocked date. Error codes: retour_type, retour, retour_datetime, retour_hours, retour_date_blocked, retour_order. The booking is stored as two linked bookings: the request id and `<id>-ret`.",
            "properties": {
              "date": {
                "type": "string",
                "format": "date"
              },
              "time": {
                "type": "string",
                "pattern": "^\\d{2}:\\d{2}$"
              },
              "flight": {
                "type": "string",
                "maxLength": 24
              },
              "meetGreet": {
                "type": "boolean",
                "default": false
              }
            },
            "required": [
              "date",
              "time"
            ]
          },
          "routeId": {
            "type": "string",
            "description": "Fixed routes only; ids from /data/rates.json. The server reprices to the route the address belongs to.",
            "enum": [
              "ams-spl",
              "noord-spl",
              "zaandam-spl",
              "aveen-spl",
              "haarlem-spl",
              "zandvoort-spl",
              "dh-spl",
              "rtm-spl",
              "almere-spl",
              "almere-buiten-spl",
              "utrecht-spl"
            ]
          },
          "direction": {
            "type": "string",
            "enum": [
              "to",
              "from"
            ],
            "description": "Fixed routes only: to or from Schiphol."
          },
          "address": {
            "type": "string",
            "minLength": 4,
            "maxLength": 180,
            "description": "Fixed routes only: the non-airport pickup/drop-off address. The fare follows the route zone of the address (postcode first, then town name). An address whose Dutch postcode is in no fixed-route zone is refused with error code address_zone; book it as a custom ride."
          },
          "meetGreet": {
            "type": "boolean",
            "description": "Optional €25 arrivals-area name card; only for direction=from."
          },
          "pickup": {
            "type": "string",
            "description": "Required for custom rides and tours: agreed pickup address or hotel, preserved in review and confirmations.",
            "minLength": 4,
            "maxLength": 180
          },
          "dropoff": {
            "type": "string",
            "description": "Required for custom rides: drop-off address.",
            "minLength": 4,
            "maxLength": 180
          },
          "tourIds": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "amsterdam-city-tour",
                "countryside-tour",
                "keukenhof-tour",
                "aalsmeer-flower-auction",
                "dutch-water-defence-line",
                "giethoorn-day-trip",
                "south-holland-tour",
                "rotterdam-kinderdijk",
                "kroller-muller",
                "efteling-day-trip",
                "bruges-day-trip",
                "roermond-outlet",
                "mall-of-the-netherlands",
                "batavia-stad",
                "style-outlet-amsterdam"
              ]
            },
            "minItems": 1,
            "maxItems": 5,
            "description": "Tours only; ids from /data/tours.json.",
            "uniqueItems": true
          },
          "ticketService": {
            "type": "boolean",
            "description": "Tours only: attraction ticket purchase assistance, requires 28 days' notice."
          },
          "driverName": {
            "type": "string",
            "maxLength": 60
          },
          "flight": {
            "type": "string",
            "maxLength": 24
          },
          "notes": {
            "type": "string",
            "maxLength": 1000
          },
          "lang": {
            "type": "string",
            "enum": [
              "en",
              "nl",
              "de",
              "fr",
              "es",
              "pt",
              "ru",
              "ar",
              "zh-CN"
            ]
          }
        }
      },
      "BookRequest": {
        "type": "object",
        "allOf": [
          {
            "$ref": "#/components/schemas/RideRequirements"
          }
        ],
        "required": [
          "type",
          "car",
          "date",
          "time",
          "passengers",
          "name",
          "email",
          "phone",
          "acceptedTerms",
          "quoteToken"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "fixed",
              "custom",
              "tour"
            ]
          },
          "car": {
            "type": "string",
            "enum": [
              "eclass",
              "sclass",
              "vclass"
            ],
            "description": "E-Class max 4, S-Class max 2, V-Class max 7 passengers."
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Local date in Europe/Amsterdam."
          },
          "time": {
            "type": "string",
            "pattern": "^\\d{2}:\\d{2}$",
            "description": "Local time in Europe/Amsterdam, between 04:00 and 20:00."
          },
          "passengers": {
            "type": "integer",
            "minimum": 1,
            "maximum": 7
          },
          "luggageLarge": {
            "type": "integer",
            "minimum": 0,
            "maximum": 8,
            "default": 0,
            "description": "Large check-in suitcases. E-Class and S-Class take at most 2; more needs the V-Class (error code luggage)."
          },
          "luggageSmall": {
            "type": "integer",
            "minimum": 0,
            "maximum": 8,
            "default": 0,
            "description": "Pieces of hand luggage. E-Class and S-Class take at most 2; more needs the V-Class (error code luggage)."
          },
          "returnTrip": {
            "type": "object",
            "description": "Fixed rides only. Books the return leg as well: the opposite direction between the same address and Schiphol, with the same vehicle, passengers and luggage. It is priced as a second ride (plus the name-card fee when meetGreet is set on a return that starts at Schiphol). It must be at least one hour after the first ride, between 04:00 and 20:00, and not on a blocked date. Error codes: retour_type, retour, retour_datetime, retour_hours, retour_date_blocked, retour_order. The booking is stored as two linked bookings: the request id and `<id>-ret`.",
            "properties": {
              "date": {
                "type": "string",
                "format": "date"
              },
              "time": {
                "type": "string",
                "pattern": "^\\d{2}:\\d{2}$"
              },
              "flight": {
                "type": "string",
                "maxLength": 24
              },
              "meetGreet": {
                "type": "boolean",
                "default": false
              }
            },
            "required": [
              "date",
              "time"
            ]
          },
          "bundle": {
            "type": "array",
            "minItems": 1,
            "maxItems": 4,
            "items": {
              "$ref": "#/components/schemas/BookRequest"
            },
            "description": "More rides booked together with this one (up to 5 rides in total). Every ride is a complete request with its own bookingId and quoteToken and is validated and price-checked on its own; the error carries `rideIndex` (0 = the outer request) when one is refused. Tours and custom rides in the request share ONE SumUp payment of the sum of each ride's 25% (`paymentRequired`: true with one `checkoutUrl`); fixed rides stay pay-in-vehicle requests. Each ride is stored as its own booking. Resending the same rides is idempotent; a different set of rides under the same booking ids is refused with `booking_conflict` (409). The response lists `bookingIds`."
          },
          "routeId": {
            "type": "string",
            "description": "Fixed routes only; ids from /data/rates.json. The server reprices to the route the address belongs to.",
            "enum": [
              "ams-spl",
              "noord-spl",
              "zaandam-spl",
              "aveen-spl",
              "haarlem-spl",
              "zandvoort-spl",
              "dh-spl",
              "rtm-spl",
              "almere-spl",
              "almere-buiten-spl",
              "utrecht-spl"
            ]
          },
          "direction": {
            "type": "string",
            "enum": [
              "to",
              "from"
            ],
            "description": "Fixed routes only: to or from Schiphol."
          },
          "address": {
            "type": "string",
            "minLength": 4,
            "maxLength": 180,
            "description": "Fixed routes only: the non-airport pickup/drop-off address. The fare follows the route zone of the address (postcode first, then town name). An address whose Dutch postcode is in no fixed-route zone is refused with error code address_zone; book it as a custom ride."
          },
          "meetGreet": {
            "type": "boolean",
            "description": "Optional €25 arrivals-area name card; only for direction=from."
          },
          "pickup": {
            "type": "string",
            "description": "Required for custom rides and tours: agreed pickup address or hotel, preserved in review and confirmations.",
            "minLength": 4,
            "maxLength": 180
          },
          "dropoff": {
            "type": "string",
            "description": "Required for custom rides: drop-off address.",
            "minLength": 4,
            "maxLength": 180
          },
          "tourIds": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "amsterdam-city-tour",
                "countryside-tour",
                "keukenhof-tour",
                "aalsmeer-flower-auction",
                "dutch-water-defence-line",
                "giethoorn-day-trip",
                "south-holland-tour",
                "rotterdam-kinderdijk",
                "kroller-muller",
                "efteling-day-trip",
                "bruges-day-trip",
                "roermond-outlet",
                "mall-of-the-netherlands",
                "batavia-stad",
                "style-outlet-amsterdam"
              ]
            },
            "minItems": 1,
            "maxItems": 5,
            "description": "Tours only; ids from /data/tours.json.",
            "uniqueItems": true
          },
          "ticketService": {
            "type": "boolean",
            "description": "Tours only: attraction ticket purchase assistance, requires 28 days' notice."
          },
          "driverName": {
            "type": "string",
            "maxLength": 60
          },
          "flight": {
            "type": "string",
            "maxLength": 24
          },
          "notes": {
            "type": "string",
            "maxLength": 1000
          },
          "lang": {
            "type": "string",
            "enum": [
              "en",
              "nl",
              "de",
              "fr",
              "es",
              "pt",
              "ru",
              "ar",
              "zh-CN"
            ]
          },
          "bookingId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{8,80}$",
            "description": "Optional. Client-generated id; re-sending the identical request with the same id is idempotent and a changed request needs a new id. When left out, the server derives a stable id (api-…) from the quoteToken and email, so retries never book twice."
          },
          "quoteToken": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "description": "Signed token returned by POST /api/quote. Send unchanged only after the customer approves that quote's total and deposit. It binds the normalized journey, vehicle, passengers, options, fare, rules version and 15-minute expiry. HTTP 409 reviewRequired=true means obtain a new quote and renewed customer approval before retrying. Never supply your own price."
          },
          "name": {
            "type": "string",
            "minLength": 2,
            "maxLength": 100
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "phone": {
            "type": "string",
            "minLength": 7,
            "maxLength": 30
          },
          "company": {
            "type": "string",
            "description": "Honeypot for the website's hidden field. Must be omitted or empty; a non-empty value is treated as automated traffic and the request is acknowledged with suppressed:true without creating a booking, payment or email."
          },
          "acceptedTerms": {
            "type": "boolean",
            "const": true,
            "description": "Must be an explicit act by the customer after reading the Terms & Conditions. Agents must not set this on a customer's behalf without their consent."
          }
        }
      },
      "QuoteResponse": {
        "type": "object",
        "required": [
          "ok",
          "eligible",
          "quoteToken",
          "rulesVersion",
          "quotedAt",
          "validUntil",
          "quote"
        ],
        "properties": {
          "quoteToken": {
            "type": "string",
            "description": "Pass to POST /api/book after explicit customer review and approval. Not a booking or availability guarantee."
          },
          "ok": {
            "type": "boolean"
          },
          "eligible": {
            "type": "boolean"
          },
          "availability": {
            "const": "not_guaranteed"
          },
          "rulesVersion": {
            "type": "string"
          },
          "quotedAt": {
            "type": "string",
            "format": "date-time"
          },
          "validUntil": {
            "type": "string",
            "format": "date-time"
          },
          "timezone": {
            "const": "Europe/Amsterdam"
          },
          "currency": {
            "const": "EUR"
          },
          "vatIncluded": {
            "type": "boolean"
          },
          "quote": {
            "type": "object",
            "properties": {
              "type": {
                "type": "string"
              },
              "car": {
                "type": "string"
              },
              "routeId": {
                "type": "string"
              },
              "routeLabel": {
                "type": "string"
              },
              "tourIds": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "basePrice": {
                "type": "number"
              },
              "meetGreetFee": {
                "type": "number"
              },
              "totalPrice": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "depositCents": {
                "type": "integer"
              },
              "payOnline": {
                "type": "string",
                "enum": [
                  "deposit_online_at_booking",
                  "none"
                ],
                "description": "deposit_online_at_booking: a deposit is paid online through the secure payment page when booking (tours and custom rides). none: nothing is paid online; the fare is paid in the vehicle."
              },
              "payInVehicle": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "pickup": {
                "type": "string"
              },
              "dropoff": {
                "type": "string"
              },
              "address": {
                "type": "string"
              },
              "direction": {
                "type": "string"
              },
              "meetGreet": {
                "type": "boolean"
              },
              "ticketService": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "BookResponse": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "paymentRequired": {
            "type": "boolean"
          },
          "checkoutUrl": {
            "type": "string",
            "format": "uri"
          },
          "bookingId": {
            "type": "string"
          },
          "emailConfirmationRequired": {
            "type": "boolean",
            "description": "True when fixed rides in this request wait for the customer to click the Confirm link emailed to them (requests without the website's Origin header). Nothing reaches the driver before that click; unconfirmed requests are deleted after two days."
          },
          "bookingIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Booking ids of every ride in a request with several rides (bundle); bookingId is the first."
          },
          "suppressed": {
            "type": "boolean",
            "description": "True when the request was silently dropped as spam (honeypot). No booking exists."
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "reviewRequired": {
            "type": "boolean",
            "description": "On HTTP 409: no checkout or booking is created. Get a new quote and ask the customer to approve its total and deposit again."
          },
          "error": {
            "type": "string"
          },
          "bookingId": {
            "type": "string"
          }
        },
        "required": [
          "error"
        ],
        "description": "error is one of: address, address_zone, agent_capacity, blocked, body, body_too_large, booking_id, booking_id_conflict, bundle, capacity, car, content_type, custom_estimate_failed, date_blocked, date_range, datetime, direction, driver_name, dropoff, email, email_disposable, email_domain, flight, hours, internal, lead_time, luggage, mail_unavailable, meet_greet_direction, method, name, notes, origin, payment_failed, payment_unavailable, phone, pickup, quote_changed, quote_expired, quote_invalid, quote_mismatch, quote_required, quote_unavailable, rate_limit, rate_limit_contact, request_failed, retour, retour_date_blocked, retour_datetime, retour_hours, retour_order, retour_type, route, terms, ticket_lead_time, tour, tour_duplicate, tour_limit, tour_price, tour_season, type."
      },
      "RideRequirements": {
        "type": "object",
        "properties": {
          "pickup": {
            "type": "string",
            "minLength": 4,
            "maxLength": 180,
            "description": "Required for custom rides and tours: agreed pickup address or hotel, preserved in review and confirmations."
          },
          "dropoff": {
            "type": "string",
            "minLength": 4,
            "maxLength": 180
          },
          "tourIds": {
            "type": "array",
            "uniqueItems": true
          },
          "email": {
            "type": "string",
            "maxLength": 254
          },
          "phone": {
            "type": "string",
            "pattern": "^[+()0-9 .-]{7,30}$"
          }
        },
        "allOf": [
          {
            "if": {
              "properties": {
                "type": {
                  "const": "fixed"
                }
              },
              "required": [
                "type"
              ]
            },
            "then": {
              "required": [
                "routeId",
                "direction",
                "address"
              ]
            }
          },
          {
            "if": {
              "properties": {
                "type": {
                  "const": "custom"
                }
              },
              "required": [
                "type"
              ]
            },
            "then": {
              "required": [
                "pickup",
                "dropoff"
              ]
            }
          },
          {
            "if": {
              "properties": {
                "type": {
                  "const": "tour"
                }
              },
              "required": [
                "type"
              ]
            },
            "then": {
              "required": [
                "tourIds",
                "pickup"
              ]
            }
          },
          {
            "if": {
              "properties": {
                "car": {
                  "const": "eclass"
                }
              },
              "required": [
                "car"
              ]
            },
            "then": {
              "properties": {
                "passengers": {
                  "maximum": 4
                }
              }
            }
          },
          {
            "if": {
              "properties": {
                "car": {
                  "const": "sclass"
                }
              },
              "required": [
                "car"
              ]
            },
            "then": {
              "properties": {
                "passengers": {
                  "maximum": 2
                }
              }
            }
          },
          {
            "if": {
              "properties": {
                "type": {
                  "const": "fixed"
                },
                "meetGreet": {
                  "const": true
                }
              },
              "required": [
                "type",
                "meetGreet"
              ]
            },
            "then": {
              "properties": {
                "direction": {
                  "const": "from"
                }
              }
            }
          }
        ]
      }
    }
  }
}
