openapi: 3.0.3
info:
  title: Fly & Drive Connect API
  version: 1.0.0
  description: |
    REST API for third-party integrators to search drive inventory (cars and camper vans)
    and create bookings on the Fly & Drive OTA.

    ## Base URL

    Canonical API host:

    `https://api.fly-and-drive.com/v1`

    Legacy (supported): `https://developer.fly-and-drive.com/api/connect/v1`

    The embed widget loader lives at `https://www.fly-and-drive.com/connect/widget.js`
    and reads theme config from `https://api.fly-and-drive.com/widget-config`.

    ## Authentication

    Send your API key using **one** of:

    - `Authorization: Bearer fd_connect_test_...` (sandbox)
    - `Authorization: Bearer fd_connect_live_...` (production)
    - Header `X-Fly-Drive-Partner-Key: fd_connect_...`
    - Query parameter `?key=fd_connect_...` (widget config only)

    Key prefix determines environment — not the hostname.

    ## IP allowlist (optional)

    Restrict server-side API usage to specific egress IPs or CIDR ranges per key.
    Configure in the [developer dashboard](https://developer.fly-and-drive.com/developer/dashboard?section=keys)
    or via `PUT /api/partner/connect/keys/allowlist` (dashboard session auth).

    - **Empty allowlist** (default): all client IPs allowed.
    - **Non-empty allowlist**: only listed IPv4/IPv6 addresses or CIDR blocks may call Connect v1 routes.
    - **Widget config** (`GET /widget-config`) is **not** IP-restricted — it is loaded from end-user browsers.
    - Denied requests return **HTTP 403** with `{ "error": "...", "clientIp": "<detected-ip>" }` to aid debugging.

    Up to **32** entries per key (e.g. `203.0.113.10`, `198.51.100.0/24`, `2001:db8::/32`).

    ## Environments

    | Key prefix | Inventory | Payment |
    |------------|-----------|---------|
    | `fd_connect_test_` | Sandbox partners only (`is_sandbox`) | Internal wallet |
    | `fd_connect_live_` | Production fleet | Stripe Checkout |

    Test keys **cannot** book live inventory (HTTP 403). Live keys **cannot** book sandbox inventory.

    ## Rate limiting

    **10 requests per second** per Connect partner. Every response includes:

    - `X-RateLimit-Limit: 10`
    - `X-RateLimit-Remaining: <n>`
    - `X-RateLimit-Reset: <unix-seconds>` (when using Upstash)
    - `Retry-After: <seconds>` on HTTP 429

    Exceeding the limit returns **HTTP 429** with `Retry-After` (seconds).

    Limits are enforced globally via **Upstash Redis** when `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are configured (required in production on Vercel). Check `GET /` discovery — `rateLimit.distributed` should be `true`.

    ## Idempotency (bookings)

    Pass header `Idempotency-Key` (1–256 characters) on `POST /bookings`.

    - **Replay:** Same key within **24 hours** returns the stored response with `idempotentReplay: true`.
    - **Concurrent duplicate:** Returns **HTTP 409** while the first request is in progress.
    - **Failed booking:** The key is released; you may retry with the same key.

    ## Webhooks (outbound)

    Register test and live URLs separately in the [developer dashboard](https://developer.fly-and-drive.com/developer/dashboard).

    | Event | When |
    |-------|------|
    | `booking.created` | Booking submitted, pending partner approval |
    | `booking.confirmed` | Partner accepted |
    | `booking.rejected` | Partner rejected or approval timeout |
    | `booking.cancelled` | Customer cancelled via mobile app |

    Deliveries are signed: `Fly-Drive-Signature: t={unix},v1={hmac_sha256_hex}` over `{timestamp}.{raw_body}`.
    Failed deliveries retry automatically (3 attempts, 60s then 5min backoff).

    Machine-readable spec: `GET https://api.fly-and-drive.com/openapi`

servers:
  - url: https://api.fly-and-drive.com/v1
    description: Connect API v1 (canonical)
  - url: https://developer.fly-and-drive.com/api/connect/v1
    description: Legacy (same routes, portal origin)

tags:
  - name: Search
    description: Vehicle inventory search
  - name: Bookings
    description: Create and retrieve bookings
  - name: Widget
    description: Embed widget theme (served at /widget-config on api host)

security:
  - BearerAuth: []

paths:
  /search/drive:
    get:
      summary: Search vehicle offers at an airport
      operationId: searchDrive
      tags: [Search]
      parameters:
        - $ref: '#/components/parameters/AirportQuery'
        - $ref: '#/components/parameters/FromDateQuery'
        - $ref: '#/components/parameters/ToDateQuery'
      responses:
        '200':
          description: Offer list
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OfferList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Missing `search:drive` scope, or request IP not on the key allowlist
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /bookings:
    post:
      summary: Create a drive booking from a selected offer
      operationId: createBooking
      tags: [Bookings]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBookingRequest'
            example:
              offerId: partner-uuid-agency-uuid-0
              airportIata: NCE
              pickupDate: '2026-09-01'
              returnDate: '2026-09-08'
              pickupTime: '10:00'
              returnTime: '10:00'
              customer:
                firstName: Jane
                lastName: Doe
                email: jane@example.com
                phone: '+33612345678'
              anytimeFlex:
                selected: false
      responses:
        '201':
          description: Booking created (or demo mode when Stripe is unavailable)
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingCreated'
              examples:
                pending:
                  summary: Standard response (test or live)
                  value:
                    object: booking
                    environment: test
                    bookingId: 550e8400-e29b-41d4-a716-446655440000
                    reservationNumber: FD-2026-ABC123
                    status: pending_partner_approval
                    paymentUrl: null
                    paidWithPay: true
                    createdAt: '2026-09-01T10:00:00.000Z'
                idempotent_replay:
                  summary: Idempotency replay
                  value:
                    object: booking
                    environment: test
                    bookingId: 550e8400-e29b-41d4-a716-446655440000
                    reservationNumber: FD-2026-ABC123
                    status: pending_partner_approval
                    idempotentReplay: true
                demo:
                  summary: Stripe not configured (non-production only)
                  value:
                    object: booking
                    environment: test
                    status: demo
                    message: Stripe not configured — demo mode only
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Missing scope, live key required, sandbox/production inventory mismatch, or request IP not on the key allowlist
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                sandbox_isolation:
                  value:
                    error: Test API keys cannot book production inventory. Use offers returned from a test key search.
        '409':
          description: Idempotency-Key already in use by an in-flight request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: A request with this Idempotency-Key is already in progress. Retry shortly.
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /bookings/{id}:
    get:
      summary: Retrieve a booking created via Connect API
      operationId: getBooking
      tags: [Bookings]
      parameters:
        - name: id
          in: path
          required: true
          description: Booking UUID returned by POST /bookings
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Booking details (scoped to your Connect partner)
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Missing `book:drive` scope, or request IP not on the key allowlist
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Booking not found or not owned by this integrator
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /bookings/{id}/voucher:
    get:
      summary: Download PDF rental voucher for a confirmed booking
      operationId: getBookingVoucher
      tags: [Bookings]
      parameters:
        - name: id
          in: path
          required: true
          description: Booking UUID returned by POST /bookings
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: PDF voucher (application/pdf)
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            Content-Disposition:
              schema:
                type: string
                example: attachment; filename="FlyDrive-FD-2026-ABC123.pdf"
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Missing `voucher` scope, or request IP not on the key allowlist
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Booking not found or not owned by this integrator
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Booking not confirmed yet (voucher unavailable)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  # Widget config lives at /api/connect/widget-config (not under /v1).
  # Path-level server override resolves the full URL for OpenAPI tools.
  /widget-config:
    get:
      summary: Load embed widget theme for an API key
      description: |
        Returns accent colors, logo, language, and allowed domains for the
        [Connect embed widget](https://www.fly-and-drive.com/connect/widget.js).

        **Note:** Canonical host: `GET https://api.fly-and-drive.com/widget-config?key=fd_connect_test_...`
      operationId: getWidgetConfig
      tags: [Widget]
      servers:
        - url: https://api.fly-and-drive.com
          description: Widget config (api host, no /v1 suffix)
      security:
        - BearerAuth: []
        - ApiKeyQuery: []
      parameters:
        - name: key
          in: query
          required: false
          description: API key (alternative to Authorization header — used by widget.js)
          schema:
            type: string
            example: fd_connect_test_abc123
      responses:
        '200':
          description: Widget theme
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WidgetConfigResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        API secret key. Prefix determines environment:
        `fd_connect_test_*` (sandbox) or `fd_connect_live_*` (production).
    ApiKeyQuery:
      type: apiKey
      in: query
      name: key
      description: API key as query parameter (widget loader only)

  parameters:
    AirportQuery:
      name: airport
      in: query
      required: true
      description: IATA airport code (aliases `pickup` also accepted)
      schema:
        type: string
        example: NCE
    FromDateQuery:
      name: from
      in: query
      required: true
      description: Pickup date YYYY-MM-DD (alias `pickup`)
      schema:
        type: string
        format: date
        example: '2026-09-01'
    ToDateQuery:
      name: to
      in: query
      required: true
      description: Return date YYYY-MM-DD (alias `return`)
      schema:
        type: string
        format: date
        example: '2026-09-08'
    IdempotencyKeyHeader:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Unique key (1–256 chars) for safe booking retries. Stored 24 hours.
        Replays return the original response. Concurrent duplicates return HTTP 409.
      schema:
        type: string
        minLength: 1
        maxLength: 256
        example: booking-req-7f3a9c2e-2026-09-01

  headers:
    XRateLimitLimit:
      description: Maximum requests per second for this partner
      schema:
        type: integer
        example: 10
    XRateLimitRemaining:
      description: Remaining requests in the current 1-second window
      schema:
        type: integer
        example: 9
    XRateLimitReset:
      description: Unix timestamp (seconds) when the rate limit window resets
      schema:
        type: integer
        example: 1723046401
    RetryAfter:
      description: Seconds to wait before retrying (HTTP 429 only)
      schema:
        type: integer
        example: 1

  responses:
    Unauthorized:
      description: Invalid or missing API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: 'Unauthorized · provide Authorization: Bearer fd_connect_... or X-Fly-Drive-Partner-Key'
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Rate limit exceeded (10 req/s per partner)
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Too Many Requests. Please slow down.
    ServiceUnavailable:
      description: Database or upstream service unavailable
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
        clientIp:
          type: string
          description: Detected client IP (included on IP allowlist 403 responses)

    OfferList:
      type: object
      required: [object, environment, count, data]
      properties:
        object:
          type: string
          enum: [list]
        environment:
          type: string
          enum: [live, test]
        partnerId:
          type: string
          format: uuid
        commissionBps:
          type: integer
          example: 800
        count:
          type: integer
        data:
          type: array
          items:
            $ref: '#/components/schemas/Offer'

    Offer:
      type: object
      required: [id, vehicle, supplier, pricing, location]
      properties:
        id:
          type: string
          description: Pass as `offerId` when creating a booking
          example: partner-uuid-agency-uuid-0
        vehicle:
          type: object
          properties:
            name:
              type: string
            category:
              type: string
            transmission:
              type: string
            seats:
              type: integer
            bags:
              type: integer
            fuelType:
              type: string
            image:
              type: string
              format: uri
        supplier:
          type: object
          properties:
            name:
              type: string
            rating:
              type: number
            reviewCount:
              type: integer
        pricing:
          type: object
          properties:
            currency:
              type: string
              example: EUR
            total:
              type: number
            payNow:
              type: number
            payAtCounter:
              type: number
            deposit:
              type: number
            pricePerDay:
              type: number
        location:
          type: object
          properties:
            pickup:
              type: string
            type:
              type: string
            distanceKm:
              type: number

    CreateBookingRequest:
      type: object
      required: [offerId, airportIata, pickupDate, customer]
      properties:
        offerId:
          type: string
          description: Offer `id` from GET /search/drive
        airportIata:
          type: string
          example: NCE
        pickupDate:
          type: string
          format: date
        returnDate:
          type: string
          format: date
          description: Alias for dropoffDate (one of returnDate or dropoffDate required)
        dropoffDate:
          type: string
          format: date
        pickupTime:
          type: string
          example: '10:00'
          default: '10:00'
        returnTime:
          type: string
          example: '10:00'
          default: '10:00'
        customer:
          type: object
          required: [email]
          properties:
            firstName:
              type: string
            lastName:
              type: string
            email:
              type: string
              format: email
            phone:
              type: string
              example: '+33612345678'
        addOns:
          type: array
          items:
            type: string
          description: Option keys to enable (maps to selectedOptions)
        selectedOptions:
          type: object
          additionalProperties:
            type: boolean
        anytimeFlex:
          type: object
          description: "Optional Anytime Flex upsell. Defaults to { selected: false } if omitted."
          properties:
            selected:
              type: boolean
              default: false

    BookingCreated:
      type: object
      required: [object, environment, status]
      properties:
        object:
          type: string
          enum: [booking]
        environment:
          type: string
          enum: [live, test]
        bookingId:
          type: string
          format: uuid
          description: Omitted in demo mode
        reservationNumber:
          type: string
          nullable: true
        status:
          type: string
          enum: [pending_partner_approval, pending_payment, demo]
          description: pending_partner_approval after sandbox auto-pay or Stripe auth; pending_payment when paymentUrl is returned (Live)
        paymentUrl:
          type: string
          format: uri
          nullable: true
          description: Stripe Checkout URL (live keys). Null when paid via wallet (test keys).
        paidWithPay:
          type: boolean
        createdAt:
          type: string
          format: date-time
        idempotentReplay:
          type: boolean
          description: True when this response was replayed from a prior Idempotency-Key
        message:
          type: string
          description: Present when status is demo

    BookingDetail:
      type: object
      properties:
        object:
          type: string
          enum: [booking]
        environment:
          type: string
          enum: [live, test]
        bookingId:
          type: string
          format: uuid
        reservationNumber:
          type: string
          nullable: true
        status:
          type: string
          description: e.g. pending_partner_approval, confirmed, cancelled
        airportIata:
          type: string
        pickupAt:
          type: string
          format: date-time
        dropoffAt:
          type: string
          format: date-time
        customer:
          type: object
          properties:
            email:
              type: string
            name:
              type: string
        voucherToken:
          type: string
          nullable: true
          description: Available when status is confirmed, active, or completed
        vehicle:
          type: object
          nullable: true
          properties:
            name:
              type: string
        location:
          type: object
          nullable: true
          properties:
            city:
              type: string

    WidgetConfigResponse:
      type: object
      properties:
        partnerId:
          type: string
          format: uuid
        environment:
          type: string
          enum: [live, test]
        theme:
          type: object
          properties:
            accentColor:
              type: string
              example: '#00E699'
            backgroundColor:
              type: string
              example: '#0a0a0a'
            lang:
              type: string
              example: en
            logoUrl:
              type: string
              format: uri
              nullable: true
            allowedDomains:
              type: array
              items:
                type: string
        searchUrl:
          type: string
          format: uri
          description: Default marketplace search URL opened by the widget

    WebhookEventPayload:
      type: object
      description: |
        Outbound webhook POST body (not an inbound API endpoint).
        Header `Fly-Drive-Event` mirrors `type`.
      properties:
        id:
          type: string
          example: evt_a1b2c3d4e5f6
        object:
          type: string
          enum: [event]
        type:
          type: string
          enum:
            - booking.created
            - booking.confirmed
            - booking.rejected
            - booking.cancelled
        created:
          type: integer
          description: Unix timestamp
        data:
          type: object
          properties:
            bookingId:
              type: string
              format: uuid
            reservationNumber:
              type: string
              nullable: true
            status:
              type: string
            environment:
              type: string
              enum: [live, test]
            airportIata:
              type: string
            pickupAt:
              type: string
              format: date-time
            dropoffAt:
              type: string
              format: date-time
            cancelledBy:
              type: string
              description: Present on booking.cancelled (e.g. customer)
            refundAmount:
              type: number
              description: Present on booking.cancelled
            penaltyAmount:
              type: number
            reason:
              type: string
              description: Present on booking.rejected (reject or timeout)
