openapi: 3.1.0
info:
  title: Getcho Business Orders API
  version: "0.1.0"
  description: |
    Public REST API that exposes the Getcho business order flow — the same
    quote → book → track → cancel/reschedule pipeline that powers the
    business-dash "Submit Order" form — to a merchant's own systems.

    > **Status: live.** Production at `https://api.getcho.app`, staging at
    > `https://staging.getcho.app`. Pin the `version` above; additive changes
    > only within a major.

    ## How it works

    1. **`POST /quotes`** — price a delivery across carriers. Unlike the form
       (which surfaces a single best price), this returns the bookable
       carriers (Uber, DoorDash, Grubhub) each as its own selectable
       quote, cheapest first.
    2. **`POST /orders`** — book the carrier you chose, scheduled to the
       quote's pickup window. **`POST /orders/dispatch`** is the same call
       but dispatches **immediately (ASAP)**.
    3. **`GET /orders/{orderId}`** — poll status, courier, ETAs, tracking
       URL, and proof of delivery.
    4. **`POST /orders/{orderId}/cancel`** / **`/reschedule`** — manage a
       booked order.

    ## Pricing

    `priceCents` is the carrier's price for the delivery. **No percentage
    markup is applied** on top of the carrier cost. Any account-level service
    fee is part of your pricing agreement and applied at invoicing, not added
    to quotes.

    ## Asynchronous dispatch

    Booking a carrier upstream (Uber/DoorDash) routinely exceeds the
    30-second proxy timeout, so `POST /orders` and `POST /orders/dispatch`
    **return immediately** once the order is created (status `PENDING`) and
    the carrier booking runs in the background. Poll `GET /orders/{orderId}`
    until the status flips to `DISPATCHED`. The `trackingUrl` is stable and
    available from the first response, before the carrier has accepted.

    ## Authentication

    All endpoints require a bearer token from the `business_api_keys` table
    with the `business_orders` scope. The resolved business is inferred from
    the key — there is no business id in the request body.

    Keys are **environment-scoped** and the environment is visible in the
    prefix:

    | Key prefix | Works on | Couriers |
    |---|---|---|
    | `gcho_live_…` | `https://api.getcho.app` | real |
    | `gcho_test_…` | `https://staging.getcho.app` | simulated |

    Using a key on the wrong host returns `401` with an explicit reason
    (`Test api key used on production …` / `Live api key used on staging …`).
    Orders are scoped by key environment too: a live key can only read,
    cancel or reschedule orders booked with a live key, and a test key only
    orders booked with a test key (`404` otherwise).
    You are issued a test key first; the live key is minted at go-live and
    the only change on your side is the base URL + token.

    ## Staging (test mode)

    `https://staging.getcho.app` runs the same code and the same database as
    production, with the **Uber Direct test-mode account** behind it. Every
    booking is served by Uber's *Robo Courier*: no real courier, no charge,
    but a full lifecycle on a fixed clock —

    | t+ | status |
    |---|---|
    | 0:00 | `DISPATCHED` (courier assigned) |
    | 0:30 | courier en route to pickup |
    | 1:30 | `PICKED_UP` |
    | 2:30 | `DELIVERED` |

    Webhooks (`delivery.*` events — registered on request during onboarding)
    fire exactly as they do in production, so you can test your listener
    end-to-end. Staging quotes come from Uber (test mode) and Grubhub
    (production pricing, flagged as test deliveries — no courier, and the
    order stays `DISPATCHED` until cancelled). DoorDash is not enabled on
    staging. Do not use
    staging for load testing.

    ## Coming from Burq?

    See the [Burq → Getcho migration guide](./burq-migration/) — an
    endpoint-by-endpoint map (auth, quote, book, track, status mapping,
    webhooks, sandbox).

    ## Postman

    - **Import the spec directly:** in Postman, *Import → Link* and paste
      `https://docs.withgetcho.com/business-orders/openapi.yaml`. The
      collection auto-refreshes when this spec changes.
    - **Or download a pre-built collection:**
      [`business-orders.postman_collection.json`](./business-orders.postman_collection.json).
      Set the `bearerToken` collection variable to your api key.

    ## Carrier support

    | Carrier | Quote | Book | Staging |
    |---|---|---|---|
    | `uber` | ✅ | ✅ | ✅ (Robo Courier) |
    | `doordash` | ✅ | ✅ | — |
    | `grubhub` | ✅ | ✅ | ✅ (test delivery — real price, no courier; stays `DISPATCHED` until cancelled) |
    | Other fleets (Roadie, Frayt, Senpex, Lugg, …) | internal only | not yet exposed | — |

    Grubhub has no ID-verification capability: booking a `grubhub` quote with
    `minAgeRequirement` set returns `400` — book the Uber or DoorDash quote
    for age-restricted orders. Grubhub orders cannot be rescheduled (`501`);
    cancel and re-book instead.

    An empty `quotes: []` means no bookable carrier serves the lane (typically
    distance — these carriers cover urban lanes up to roughly 20–30 miles).
    Try a closer dropoff or contact us for a different fleet.

servers:
  - url: https://api.getcho.app/api/v1/business-orders
    description: Production
  - url: https://staging.getcho.app/api/v1/business-orders
    description: Staging — gcho_test_ keys, Uber test mode + Robo Courier, no real couriers or charges

security:
  - bearerAuth: []

paths:
  /quotes:
    post:
      summary: Create a quote (multi-carrier, prices are final and all-in)
      description: |
        Geocodes the pickup/dropoff, fans out across the bookable carriers,
        and returns each carrier as an independently selectable quote
        (cheapest first), priced final and all-in. Quotes expire after
        ~15 minutes; each `quoteId` is single-use.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateQuoteInput'
      responses:
        '200':
          description: Quotes returned (may be empty if no carrier is serviceable)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateQuoteResponse'
        '400':
          description: Invalid input (missing pickup/dropoff/items, bad address)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          description: Quoting failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /orders:
    post:
      summary: Book a quote (scheduled to the quote's pickup window)
      description: |
        Accepts one `quoteId` from a prior `POST /quotes` and books that
        carrier. The order is created synchronously (status `PENDING`, with
        a stable `trackingUrl`) and the upstream carrier booking runs in the
        background — poll `GET /orders/{orderId}` for `DISPATCHED`.

        Dispatch is anchored to the quote's `pickupWindow` (the carrier's
        pickup window is set to the window start, not "now"). For immediate
        dispatch instead, use `POST /orders/dispatch`.

        Contact details captured at quote time are reused; supply the
        `pickup`/`dropoff` override fields to refine recipient name, phone,
        or notes at book time.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookOrderInput'
      responses:
        '200':
          description: |
            Order created. `status` is `PENDING`; the carrier booking runs
            in the background. Poll `GET /orders/{orderId}` for `DISPATCHED`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/BadOrderRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No quote found for this business
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Quote already booked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '410':
          description: Quote has expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /orders/dispatch:
    post:
      summary: Book a quote and dispatch immediately (ASAP)
      description: |
        Identical to `POST /orders` but dispatches the carrier **now** (ASAP)
        rather than anchoring to the quote's pickup window. Same request body,
        same asynchronous booking behavior, same response shape.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookOrderInput'
      responses:
        '200':
          description: |
            Order created and dispatched ASAP. `status` is `PENDING` until the
            carrier accepts; poll `GET /orders/{orderId}` for `DISPATCHED`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/BadOrderRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No quote found for this business
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Quote already booked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '410':
          description: Quote has expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /orders/{orderId}:
    get:
      summary: Get an order (status, courier, ETAs, tracking, POD)
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: integer
          description: The order id returned when the order was booked.
      responses:
        '200':
          description: Serialized order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Order not found for this business
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /orders/{orderId}/cancel:
    post:
      summary: Cancel an order
      description: |
        Marks the order canceled and makes a best-effort upstream cancel with
        the carrier. The order is canceled on our side regardless of the
        upstream outcome; if the carrier cancel fails, the `200` response
        includes a `warning` and the physical delivery may need manual
        intervention. Idempotent — cancelling an already-cancelled order
        returns the same shape.
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Cancelled (see `warning` if the upstream cancel failed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    $ref: '#/components/schemas/Order'
                  warning:
                    type: string
                    description: Present only when the upstream carrier cancel failed.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Order not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Cannot cancel an order that has already been delivered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /orders/{orderId}/reschedule:
    post:
      summary: Reschedule an order's pickup window
      description: |
        Moves the pickup window of a booked order that has not yet reached
        pickup. Rejected once the courier is at or past pickup. A new window
        equal to the current one is a no-op. Only carriers that support
        rescheduling (Uber, DoorDash) can be moved; Grubhub and others return `501`.
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pickupWindow]
              properties:
                pickupWindow:
                  $ref: '#/components/schemas/TimeWindow'
      responses:
        '200':
          description: Rescheduled
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    $ref: '#/components/schemas/Order'
        '400':
          description: Missing or invalid `pickupWindow`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Order not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Order is already at or past pickup — cannot reschedule
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '501':
          description: The carrier on this order does not support rescheduling
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: |
        `Authorization: Bearer <api_key>`. Keys live in `business_api_keys`
        with scope `business_orders`. The first 18 chars are the `key_prefix`
        used to look up the row; the full token is verified against the
        encrypted body via constant-time compare. `gcho_live_` keys work only
        on production, `gcho_test_` keys only on staging. The business is inferred
        from the key.

  responses:
    Unauthorized:
      description: Missing, invalid, or out-of-scope api key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    BadOrderRequest:
      description: |
        Missing `quoteId`, otherwise invalid book request, or the chosen
        carrier cannot fulfil the order (e.g. `minAgeRequirement` on a
        `grubhub` quote — `reason` explains).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
        reason:
          type: string
        message:
          type: string

    AddressIn:
      type: object
      required: [address, name, phone]
      properties:
        address:
          type: string
          description: One-line formatted address. Geocoded server-side.
        name:
          type: string
        phone:
          type: string
          description: E.164. A relay number is substituted for the courier.
        instructions:
          type: string
          description: Delivery / pickup notes for the courier.
        email:
          type: string
        lat:
          type: number
        lng:
          type: number

    ItemIn:
      type: object
      required: [description, quantity, weight, length, width, height]
      properties:
        description:
          type: string
        quantity:
          type: integer
        weight:
          type: number
          description: Pounds.
        length:
          type: number
          description: Inches.
        width:
          type: number
          description: Inches.
        height:
          type: number
          description: Inches.
        value:
          type: number
          description: Declared value in dollars (optional).
        size:
          type: string
          enum: [small, medium, large, extra_large, huge]

    TimeWindow:
      type: object
      required: [start, end]
      properties:
        start:
          type: integer
          description: Unix epoch ms.
        end:
          type: integer
          description: Unix epoch ms.

    CreateQuoteInput:
      type: object
      required: [pickup, dropoff, items]
      properties:
        pickup:
          $ref: '#/components/schemas/AddressIn'
        dropoff:
          $ref: '#/components/schemas/AddressIn'
        items:
          type: array
          items:
            $ref: '#/components/schemas/ItemIn'
        overallSize:
          type: string
          enum: [small, medium, large, extra_large, huge]
          description: Overall shipment size. Defaults to `medium`.
        vehicleType:
          type: string
          description: Optional vehicle hint (e.g. `sedan`, `suv`, `pickup_cargo_van`).
        pickupWindow:
          $ref: '#/components/schemas/TimeWindow'
        dropoffWindow:
          $ref: '#/components/schemas/TimeWindow'
        requestProof:
          type: boolean
          description: Request photo proof of delivery.
        externalRef:
          type: string
          description: Caller-provided tracking key, echoed where applicable.

    QuoteOption:
      type: object
      description: One bookable carrier option. `priceCents` is the carrier's price with no markup.
      properties:
        quoteId:
          type: string
          description: |
            Single-use id for this carrier option. Pass to `POST /orders`
            or `POST /orders/dispatch` to book. Expires in ~15 minutes.
        carrier:
          type: string
          description: Lowercase carrier slug.
          enum: [uber, doordash, grubhub]
        displayName:
          type: string
          description: Human-friendly carrier name (e.g. `Uber`, `DoorDash`, `Grubhub`).
        priceCents:
          type: integer
          description: |
            Price in cents for this delivery — the carrier's price, with no
            percentage markup applied.
        etaMinutes:
          type: integer
          nullable: true
          description: Estimated delivery duration in minutes, when available.
        currency:
          type: string
          default: USD

    CreateQuoteResponse:
      type: object
      properties:
        quotes:
          type: array
          description: Bookable carrier options, cheapest first.
          items:
            $ref: '#/components/schemas/QuoteOption'
        expiresAt:
          type: string
          format: date-time
          description: When the quotes expire (ISO 8601, UTC).

    BookOrderInput:
      type: object
      required: [quoteId]
      properties:
        quoteId:
          type: string
          description: A `quoteId` returned by `POST /quotes`.
        dropoff:
          $ref: '#/components/schemas/ContactOverride'
        pickup:
          $ref: '#/components/schemas/ContactOverride'
        isSignatureRequired:
          type: boolean
          description: Require a signature on delivery.
        minAgeRequirement:
          type: integer
          description: |
            Minimum recipient age (e.g. `21` for alcohol). Triggers ID
            verification at handoff.

    ContactOverride:
      type: object
      description: |
        Optional refinements to the contact captured at quote time. Any field
        omitted keeps the value from the quote.
      properties:
        name:
          type: string
        phone:
          type: string
        notes:
          type: string

    OrderStatus:
      type: string
      description: |
        Lifecycle of an order. Derived from the underlying delivery task and
        dispatch state.
      enum:
        - PENDING      # created; upstream carrier booking in flight
        - DISPATCHED   # carrier accepted the booking
        - PICKED_UP    # courier has the package
        - DELIVERED    # completed
        - CANCELED
        - FAILED       # booking failed; pick another quote or retry

    Order:
      type: object
      properties:
        orderId:
          type: integer
          description: Stable order handle. Use for GET / cancel / reschedule.
        status:
          $ref: '#/components/schemas/OrderStatus'
        carrier:
          type: string
          description: The booked carrier slug (`uber`, `doordash`, `grubhub`).
        priceCents:
          type: integer
          description: |
            Price in cents for this order — the carrier's price, with no
            percentage markup applied.
        currency:
          type: string
          default: USD
        trackingUrl:
          type: string
          format: uri
          nullable: true
          description: |
            Stable public tracking URL. Available from the first response,
            before the carrier accepts.
        pickupEta:
          type: string
          format: date-time
          nullable: true
        dropoffEta:
          type: string
          format: date-time
          nullable: true
        pickup:
          $ref: '#/components/schemas/Address'
        dropoff:
          $ref: '#/components/schemas/Address'
        courierName:
          type: string
          nullable: true
        courierPhoneNumber:
          type: string
          nullable: true
        providerDeliveryId:
          type: string
          nullable: true
          description: The carrier's own delivery id, once booked.
        proofOfDelivery:
          type: array
          description: POD photo records, populated after delivery.
          items:
            type: object
            properties:
              type:
                type: string
                enum: [photo_proof_of_delivery]
              url:
                type: string
                format: uri
        failureReason:
          type: string
          nullable: true
          description: Populated when `status` is `FAILED`.
        externalRef:
          type: string
          nullable: true
          description: The `externalRef` you passed on `POST /quotes`, echoed back on every order response.
        createdAt:
          type: string
          format: date-time

    Address:
      type: object
      description: Serialized address on an order.
      properties:
        address:
          type: string
        name:
          type: string
        phone:
          type: string
        instructions:
          type: string
        lat:
          type: number
        lng:
          type: number
