openapi: 3.0.3
info:
  title: Lakpura® Transfers API
  version: "1.0.0"
  description: |
    Point-to-point transfer quoting and booking in Sri Lanka, for transport
    partners. You request a quote, we return per-vehicle offers, and you book the
    offer you want. We fulfil with our fleet.

    ### Three things that are not obvious

    **1. Sandbox and production are the SAME base URL.** There is no `/sandbox/`
    path and no separate host — *your API key selects the environment*. A test
    key can only ever reach sandbox; going live means swapping one string, not
    changing your integration. Every response echoes the environment back in the
    `X-Lakpura-Env` header and an `env` field, so you can always tell which one
    answered.

    **2. "No availability" is a `200`, not an error.** When we cannot serve a
    request you get `200` with an empty `offers` array and a `declined` object
    explaining why. **Please do not treat that as a failure or an outage** — it
    is a normal answer, and the reason (`short_notice`, `no_vehicle`,
    `route_unknown`, …) is usually actionable.

    **3. A booking replays the price you were quoted.** Offers are locked for 30
    minutes and a booking must carry an `offer_id`; we never re-price at booking
    time, so what you were quoted is what you are billed. Once an offer expires
    you get `410 offer_expired` — request a fresh quote rather than retrying.

    ### Authentication

    Send your key as a bearer token on every request:

    ```
    Authorization: Bearer lakpura_sk_live_<key_ref>_<secret>
    ```

    (`X-Api-Key: <token>` is accepted if a bearer header is awkward in your
    stack.) Keys are issued by Lakpura and shown only once. If a key leaks, tell
    us and we will revoke it — we can issue a replacement before revoking the
    old one, so you can rotate without downtime.

    Every authentication failure returns the same `401` body, whatever the cause.

    ### Errors

    Every error has the same shape, and `error.code` is the stable part — branch
    on it, not on the prose:

    ```json
    { "error": { "code": "offer_expired", "message": "…", "details": { } } }
    ```
  contact:
    name: Lakpura Transfers API
    url: https://api.lakpura.com/transfers-onboarding.html

servers:
  - url: https://api.lakpura.com/transfers/v1
    description: Production and sandbox (your API key selects which)

tags:
  - name: Service
    description: Connectivity and key checks.
  - name: Quotes
    description: Ask what a journey costs.
  - name: Bookings
    description: Place, inspect and cancel bookings.

paths:
  /ping:
    get:
      tags: [Service]
      summary: Check your key and see which environment it reaches
      description: The cheapest call to confirm a key works. Costs nothing and books nothing.
      responses:
        "200":
          description: The key is valid.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:      { type: boolean, example: true }
                  env:     { type: string, enum: [sandbox, production], example: sandbox }
                  partner: { type: string, example: "12GoAsia" }
                  time:    { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /quotes:
    post:
      tags: [Quotes]
      summary: Price a journey
      description: |
        Returns one offer per vehicle we can serve the journey with, cheapest
        first, each locked for 30 minutes.

        **Places may be free text.** Send the address or place name you have —
        `"Shangri-La Colombo, Colombo, Sri Lanka"` or just `"Galle"` — we resolve
        it. Coordinates help when the name is ambiguous. Airports are understood
        by name (`"Colombo (CMB) Airport"`) or by `iata`, and an airport at
        either end is what makes a journey an arrival or a departure.

        Omit `vehicle_codes` to see everything we can offer you.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/QuoteRequest"
            examples:
              airportToCity:
                summary: Airport pickup, 2 passengers
                value:
                  pickup:  { name: "Colombo (CMB) Airport", iata: "CMB" }
                  dropoff: { name: "Galle" }
                  pickup_datetime: "2026-08-02T14:30:00+05:30"
                  passengers: { adults: 2, children: 0, luggage: 2 }
              hotelAddress:
                summary: Free-text hotel address, specific vehicles
                value:
                  pickup:  { name: "Shangri-La Colombo, Colombo, Sri Lanka", lat: 6.9271, lon: 79.8425 }
                  dropoff: { name: "Kandy" }
                  pickup_datetime: "2026-08-05T09:00:00+05:30"
                  passengers: { adults: 4, luggage: 4 }
                  vehicle_codes: ["STANDARD_VAN", "PREMIUM_VAN"]
                  currency: "USD"
      responses:
        "200":
          description: |
            Offers, **or** a decline. Both are `200`. Check whether `offers` is
            empty rather than the status code.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/QuoteOffered"
                  - $ref: "#/components/schemas/QuoteDeclined"
              examples:
                offered:
                  summary: Offers returned
                  value:
                    env: production
                    quote_id: "561055ebbfef36165bd76359adcc634f"
                    expires_at: "2026-07-17T11:03:47Z"
                    currency: USD
                    offers:
                      - offer_id: "a7c4e1f09b3d5628e4f1a0c73d8b2e95"
                        vehicle_code: "STANDARD_CAR"
                        vehicle: "Standard Car or similar"
                        class: "Standard"
                        price: 84.71
                        currency: USD
                        max_passengers: 3
                        max_luggage: 3
                        meet_and_greet: true
                declined:
                  summary: Declined — still a 200
                  value:
                    env: production
                    quote_id: null
                    offers: []
                    declined:
                      reason: short_notice
                      message: "Pickup is inside the 24-hour minimum lead time."
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /bookings:
    post:
      tags: [Bookings]
      summary: Book an offer
      description: |
        Books one `offer_id` from a quote at the price that offer locked in.

        **Retrying is safe**, three ways over:

        * Send an `Idempotency-Key` header. The same key with the same body
          replays the original response as `200` (rather than `201`, so you can
          tell a retry that landed from a booking that was just created). The
          same key with a *different* body is `409 idempotency_conflict` — that
          means a bug on your side, not a transient error.
        * `partner_reference` is unique per partner: re-posting the same one
          returns the existing booking as `200`.
        * An offer is consumed, not re-executed. Booking one twice never makes
          two cars.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: Any unique string per booking attempt (a UUID is ideal). Strongly recommended.
          schema: { type: string, maxLength: 120 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingRequest"
            examples:
              standard:
                value:
                  offer_id: "a7c4e1f09b3d5628e4f1a0c73d8b2e95"
                  quote_id: "561055ebbfef36165bd76359adcc634f"
                  partner_reference: "ACME-2026-99812"
                  passenger:
                    first_name: "Ada"
                    last_name: "Lovelace"
                    email: "ada@example.com"
                    phone: "+447700900123"
                  passengers: { adults: 2, children: 1 }
                  flight_number: "UL504"
                  pickup_address: "Arrivals hall, Terminal 1"
                  dropoff_address: "Jetwing Lighthouse, Galle"
                  notes: "Two large suitcases"
      responses:
        "201":
          description: Booked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "200":
          description: |
            Already booked — an idempotent replay, or a re-post of the same
            `partner_reference`. Nothing new was created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Unknown `offer_id`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            `idempotency_conflict` — same `Idempotency-Key`, different body; or
            `booking_conflict` — that offer has already been booked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "410": { $ref: "#/components/responses/OfferExpired" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /bookings/{booking_ref}:
    get:
      tags: [Bookings]
      summary: Read a booking
      parameters:
        - $ref: "#/components/parameters/BookingRef"
      responses:
        "200":
          description: The booking.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /bookings/{booking_ref}/cancel:
    post:
      tags: [Bookings]
      summary: Cancel a booking
      description: Cancelling an already-cancelled booking is not an error — retrying is safe.
      parameters:
        - $ref: "#/components/parameters/BookingRef"
      responses:
        "200":
          description: Cancelled (or already was).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer lakpura_sk_live_<key_ref>_<secret>`.
        The `live`/`test` word is only there so you can read a token at a glance —
        the key itself determines the environment; editing that word changes
        nothing.

  parameters:
    BookingRef:
      name: booking_ref
      in: path
      required: true
      description: |
        Our reference, as returned by `POST /bookings` (not your `partner_reference`).
        A Lakpura LK code — short and phone-friendly, so a driver or passenger can
        read it back to us.
      schema: { type: string, pattern: "^LK[0-9A-Z]{8}$", example: "LK7ANBH4QA" }

  schemas:
    Place:
      type: object
      description: A place. `name` may be free text; coordinates disambiguate it.
      properties:
        name: { type: string, example: "Colombo (CMB) Airport" }
        lat:  { type: number, format: double, example: 7.1808 }
        lon:  { type: number, format: double, example: 79.8841 }
        iata: { type: string, description: Airport code, if this is an airport., example: "CMB" }
      required: [name]

    Passengers:
      type: object
      properties:
        adults:   { type: integer, minimum: 0, example: 2 }
        children: { type: integer, minimum: 0, example: 1 }
        infants:  { type: integer, minimum: 0, example: 0 }
        luggage:  { type: integer, minimum: 0, description: Number of bags, used to filter vehicles., example: 2 }

    QuoteRequest:
      type: object
      required: [pickup, dropoff]
      properties:
        pickup:  { $ref: "#/components/schemas/Place" }
        dropoff: { $ref: "#/components/schemas/Place" }
        pickup_datetime:
          type: string
          format: date-time
          description: |
            Local pickup time, ISO-8601 with an offset. Journeys inside our
            24-hour minimum lead time are declined with `short_notice`.
          example: "2026-08-02T14:30:00+05:30"
        distance_km:
          type: number
          description: Optional. We use our own distance for a known route; this is a fallback.
          example: 145
        passengers: { $ref: "#/components/schemas/Passengers" }
        vehicle_codes:
          type: array
          description: |
            Optional, in **your** vocabulary (we map it to our fleet — tell us
            your codes and we will configure the mapping). Omit for everything we
            can offer. Codes we do not recognise are ignored, never rejected.
          items: { type: string }
          example: ["STANDARD_VAN", "PREMIUM_VAN"]
        currency:
          type: string
          description: ISO-4217. Defaults to the currency agreed for your account.
          example: "USD"

    Offer:
      type: object
      properties:
        offer_id:       { type: string, description: Pass this to `POST /bookings`., example: "a7c4e1f09b3d5628e4f1a0c73d8b2e95" }
        vehicle_code:   { type: string, description: Your code for this vehicle., example: "STANDARD_CAR" }
        vehicle:        { type: string, example: "Standard Car or similar" }
        class:          { type: string, enum: [Economy, Standard, Business], example: "Standard" }
        price:          { type: number, format: double, example: 84.71 }
        currency:       { type: string, example: "USD" }
        max_passengers: { type: integer, example: 3 }
        max_luggage:    { type: integer, example: 3 }
        meet_and_greet: { type: boolean, example: true }

    QuoteOffered:
      type: object
      properties:
        env:        { type: string, enum: [sandbox, production] }
        quote_id:   { type: string }
        expires_at: { type: string, format: date-time, description: Offers are dead after this. Request a new quote. }
        currency:   { type: string }
        offers:
          type: array
          description: Cheapest first. Never empty in this variant.
          items: { $ref: "#/components/schemas/Offer" }

    QuoteDeclined:
      type: object
      description: |
        We cannot serve this journey. **This is a `200`.** Do not count it as an
        error — your `offers` array is simply empty and `declined.reason` says
        why.
      properties:
        env:      { type: string, enum: [sandbox, production] }
        quote_id: { type: string, nullable: true, example: null }
        offers:
          type: array
          items: { $ref: "#/components/schemas/Offer" }
          example: []
        declined:
          type: object
          properties:
            reason:
              type: string
              enum: [short_notice, below_min_rate, no_vehicle, route_unknown, partner_paused]
              description: |
                * `short_notice` — inside our 24-hour minimum lead time. Retry with a later pickup.
                * `no_vehicle` — nothing in our fleet fits that party on that route.
                * `route_unknown` — we could not price that journey. Sending coordinates often fixes it.
                * `below_min_rate` — the journey prices below our minimum fare.
                * `partner_paused` — quoting is paused on your account. Talk to us.
            message: { type: string }

    BookingRequest:
      type: object
      required: [offer_id, partner_reference]
      properties:
        offer_id:          { type: string, description: From the quote. Must not have expired. }
        quote_id:          { type: string, description: Optional, but pass it if you have it. }
        partner_reference:
          type: string
          description: Your own reference. Unique within your account; re-posting the same one returns the existing booking.
          example: "ACME-2026-99812"
        passenger:
          type: object
          properties:
            first_name: { type: string }
            last_name:  { type: string }
            email:      { type: string, format: email }
            phone:      { type: string, description: E.164 preferred., example: "+447700900123" }
        passengers:     { $ref: "#/components/schemas/Passengers" }
        flight_number:  { type: string, description: Strongly recommended for airport pickups — we track the flight., example: "UL504" }
        pickup_address:  { type: string }
        dropoff_address: { type: string }
        notes:           { type: string }

    Booking:
      type: object
      properties:
        env:               { type: string, enum: [sandbox, production] }
        booking_ref:       { type: string, description: "Our reference (a Lakpura LK code). Use it to read the booking back or cancel it.", pattern: "^LK[0-9A-Z]{8}$", example: "LK7ANBH4QA" }
        partner_reference: { type: string }
        status:            { type: string, enum: [confirmed, cancelled, completed] }
        vehicle:           { type: string, example: "Standard Car or similar" }
        price:             { type: number, format: double, description: The price the offer locked in. }
        currency:          { type: string }
        pickup_datetime:   { type: string, format: date-time }
        meeting_instructions: { type: string }
        emergency_phone:   { type: string, example: "+94112354550" }
        created_at:        { type: string, format: date-time }
        cancelled_at:      { type: string, format: date-time, nullable: true }

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - forbidden
                - invalid_request
                - not_found
                - offer_expired
                - idempotency_conflict
                - booking_conflict
                - rate_limited
                - service_unavailable
                - internal_error
              description: Branch on this. The message is for humans and may change.
            message: { type: string }
            details:
              type: object
              description: Present only when we have something specific to add.
              additionalProperties: true

  responses:
    Unauthorized:
      description: |
        Missing, malformed, revoked or unknown key — deliberately indistinguishable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: unauthorized, message: "Invalid or missing API key." }
    BadRequest:
      description: We could not understand the request.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such booking on your account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    OfferExpired:
      description: |
        The offer is older than 30 minutes. We will not silently re-price it —
        request a new quote.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: offer_expired
              message: "That offer has expired. Request a new quote."
              details: { offer_id: "a7c4e1f0…", expired_at: "2026-07-17T11:03:47Z" }
    Unavailable:
      description: Temporarily unavailable. Safe to retry with backoff.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

security:
  - bearerAuth: []
