openapi: 3.1.0
info:
  title: QuickDispatch Admin API
  version: 1.0.0
  license:
    name: Proprietary. Use with a QuickDispatch account.
    url: https://quickdispatch.co.uk/terms
  contact:
    name: QuickDispatch developers
    email: hello@quickdispatch.co.uk
    url: https://quickdispatch.co.uk/developers/
  description: |
    The Admin API is for **couriers**: everything you do in the QuickDispatch dashboard, from code.
    Import orders, record depot scans, send booking requests, plan and optimise routes, publish runs to drivers,
    follow vans live and pull proof of delivery.

    Read-only access is included on **Growth**. Full read and write access is included on **Pro** and **Enterprise**.
    Shippers sending you work should use the [Customer API](customer-api.html) with a key you issue them.

    ## Base URLs

    | Environment | Base URL |
    |---|---|
    | Live | `https://api.quickdispatch.co.uk/admin/v1` |
    | Sandbox | `https://sandbox.api.quickdispatch.co.uk/admin/v1` |

    ## Authentication

    Create admin keys under **Developers → API keys**. Send them as a bearer token:

    ```
    Authorization: Bearer qd_live_adm_xxxxxxxxxxxxxxxx
    ```

    Each key has scopes. A request outside a key's scopes returns `403 insufficient_scope`.

    | Scope | Allows |
    |---|---|
    | `orders:read` / `orders:write` | Orders, pieces and scans |
    | `bookings:write` | Booking requests and slots |
    | `routes:read` / `routes:write` | Routes, optimisation, the route agent and runs |
    | `fleet:write` | Vehicles, drivers and depots |
    | `shippers:write` | Shipper accounts and their API keys |
    | `webhooks:write` | Webhook endpoints |

    ## Conventions

    * **Idempotency:** every `POST` accepts `Idempotency-Key`. Retries with the same key return the original result for 24 hours.
    * **Pagination:** list endpoints take `limit` (max 100) and `starting_after`, and return `has_more`.
    * **Dates and times:** dates are `YYYY-MM-DD` in the depot's local time; timestamps are ISO 8601 UTC.
    * **Async jobs:** optimisation returns `202` with a job. Poll `GET /jobs/{job_id}` or listen for `job.completed`.
    * **Errors:** `{ "error": { "code", "message", "param", "request_id" } }`, with standard HTTP status codes.
    * **Rate limits:** 300 requests per minute per key, `RateLimit-*` headers on every response, `429` with `Retry-After` when exceeded.

    ## Webhooks

    Signed with `QD-Signature: t=<timestamp>,v1=<HMAC-SHA256 hex of "<timestamp>.<raw body>">` using the endpoint secret.
    Retries with back-off for 24 hours; replay any delivery from the dashboard.
servers:
  - url: https://api.quickdispatch.co.uk/admin/v1
    description: Live
  - url: https://sandbox.api.quickdispatch.co.uk/admin/v1
    description: Sandbox
security:
  - adminKey: []
tags:
  - name: Orders
    description: Every consignment from every shipper, as the courier sees it.
  - name: Scans
    description: Depot scan-in, load-to-van and exception scans for individual pieces.
  - name: Bookings
    description: Booking requests to recipients, their replies, and booked slots.
  - name: Slots
    description: Delivery capacity by day and zone.
  - name: Routes
    description: Plan and optimise the day's routes.
  - name: Route agent
    description: Suggestions from the AI route agent (Pro and Enterprise). Nothing changes until you accept.
  - name: Runs
    description: A run is one vehicle's route for one day, as the driver sees it.
  - name: Live tracking
    description: Where every vehicle is now.
  - name: Vehicles
    description: Vans and trucks, with the load space and crew size the optimiser plans around.
  - name: Drivers
    description: Driver accounts for the QuickDispatch driver app.
  - name: Depots
    description: Where goods are received, scanned and loaded.
  - name: Shippers
    description: The retailers you deliver for, and their Customer API keys.
  - name: Jobs
    description: Long-running work such as route optimisation.
  - name: Webhook endpoints
    description: Where we send events about orders, bookings, routes and runs.
x-tagGroups:
  - name: Work
    tags: [Orders, Scans, Bookings, Slots]
  - name: Planning
    tags: [Routes, Route agent, Jobs]
  - name: On the road
    tags: [Runs, Live tracking]
  - name: Setup
    tags: [Vehicles, Drivers, Depots, Shippers, Webhook endpoints]
paths:
  /orders:
    get:
      tags: [Orders]
      operationId: listOrders
      summary: List orders
      parameters:
        - { name: status, in: query, schema: { $ref: '#/components/schemas/OrderStatus' } }
        - { name: shipper, in: query, description: 'Shipper ID.', schema: { type: string } }
        - { name: date, in: query, description: 'Booked delivery date.', schema: { type: string, format: date } }
        - { name: depot, in: query, schema: { type: string } }
        - { name: postcode_prefix, in: query, description: 'e.g. `LS6`', schema: { type: string } }
        - { name: updated_since, in: query, schema: { type: string, format: date-time } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: A page of orders.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data: { type: array, items: { $ref: '#/components/schemas/Order' } }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Orders]
      operationId: createOrder
      summary: Create an order for a shipper
      description: Creates an order on a shipper's behalf, for example from a spreadsheet or phone order.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [shipper]
                  properties:
                    shipper: { type: string, examples: [shp_Oak1] }
                    depot: { type: string, examples: [dep_leeds] }
                - $ref: '#/components/schemas/OrderInput'
            example:
              shipper: shp_Oak1
              depot: dep_leeds
              reference: OAK-48213
              recipient: { name: Sarah Khan, phone: '+447700900123', address: { line1: 14 Headingley Avenue, town: Leeds, postcode: LS6 2AB } }
              items: [{ sku: DIVAN-4FT6-GREY, description: 'Divan base 4ft6 Grey', pieces: 2, volume_m3: 0.9, weight_kg: 58 }]
              service: { crew: 2 }
      responses:
        '201':
          description: Order created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /orders/{order_id}:
    parameters:
      - $ref: '#/components/parameters/OrderId'
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Retrieve an order
      responses:
        '200':
          description: The order.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Orders]
      operationId: updateOrder
      summary: Update an order
      description: Change recipient details, notes, service or depot. Changes to a booked order re-check slot capacity.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                recipient: { $ref: '#/components/schemas/Recipient' }
                service: { $ref: '#/components/schemas/Service' }
                depot: { type: string }
                notes: { type: string }
                hold: { type: boolean, description: 'Keep the order out of booking and planning until released.' }
      responses:
        '200':
          description: The updated order.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '409': { $ref: '#/components/responses/Conflict' }
  /orders/{order_id}/pieces:
    parameters:
      - $ref: '#/components/parameters/OrderId'
    get:
      tags: [Orders]
      operationId: listOrderPieces
      summary: List an order's pieces
      responses:
        '200':
          description: Pieces with their latest scan.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Piece' } }
  /scans:
    post:
      tags: [Scans]
      operationId: createScans
      summary: Record scans
      description: |
        Record one or more barcode scans from a depot scanner or your own warehouse system.
        Scanning the last expected piece moves the order to `scanned` and, if booking is automatic, sends the booking request.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scans]
              properties:
                scans:
                  type: array
                  maxItems: 500
                  items: { $ref: '#/components/schemas/ScanInput' }
            example:
              scans:
                - { barcode: QD8KQ2LM0101, type: depot_in, depot: dep_leeds, at: '2026-10-13T07:44:10Z' }
                - { barcode: QD8KQ2LM0102, type: depot_in, depot: dep_leeds, at: '2026-10-13T07:44:31Z' }
      responses:
        '201':
          description: Scans recorded. Unknown barcodes are returned in `rejected`, not failed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  accepted: { type: integer, examples: [2] }
                  rejected:
                    type: array
                    items: { type: object, properties: { barcode: { type: string }, reason: { type: string, examples: [unknown_barcode] } } }
                  orders_completed: { type: array, items: { type: string }, description: 'Orders that are now fully scanned.' }
  /bookings/requests:
    post:
      tags: [Bookings]
      operationId: sendBookingRequests
      summary: Send booking requests
      description: Sends an SMS and/or email to each recipient offering available slots. Replies are read and booked automatically where possible.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                orders: { type: array, items: { type: string }, description: 'Order IDs. Omit and use `filter` to send to every eligible order.' }
                filter: { type: object, properties: { status: { const: scanned }, depot: { type: string } } }
                channels: { type: array, items: { type: string, enum: [sms, email] }, default: [sms, email] }
                offer_from: { type: string, format: date, description: 'Earliest date to offer.' }
                template: { type: string, examples: [tpl_booking_default] }
            example: { filter: { status: scanned, depot: dep_leeds }, channels: [sms, email] }
      responses:
        '202':
          description: Requests queued.
          content:
            application/json:
              schema: { type: object, properties: { queued: { type: integer, examples: [22] }, skipped: { type: integer, examples: [1] } } }
  /bookings/replies:
    get:
      tags: [Bookings]
      operationId: listReplies
      summary: List replies
      description: Recipient replies with what the system understood. Use `needs_review=true` for the ones it could not book with confidence.
      parameters:
        - { name: needs_review, in: query, schema: { type: boolean } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: Replies.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Reply' } }
                  has_more: { type: boolean }
  /bookings/replies/{reply_id}/resolve:
    parameters:
      - { name: reply_id, in: path, required: true, schema: { type: string, examples: [rpl_7Wc2] } }
    post:
      tags: [Bookings]
      operationId: resolveReply
      summary: Resolve a reply
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [accept_suggestion, book_slot, send_message, dismiss] }
                slot: { $ref: '#/components/schemas/Slot' }
                message: { type: string }
      responses:
        '200':
          description: The resolved reply.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reply' }
  /bookings:
    post:
      tags: [Bookings]
      operationId: bookSlot
      summary: Book a slot directly
      description: Book a slot yourself, for example after a phone call. The recipient gets a confirmation text.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order, slot]
              properties:
                order: { type: string, examples: [ord_8Kq2Lm] }
                slot: { $ref: '#/components/schemas/Slot' }
                notify: { type: boolean, default: true }
      responses:
        '201':
          description: Booked.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '409':
          description: The slot is full.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /slots:
    get:
      tags: [Slots]
      operationId: listSlots
      summary: List slot capacity
      parameters:
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, required: true, schema: { type: string, format: date } }
        - { name: depot, in: query, schema: { type: string } }
        - { name: zone, in: query, schema: { type: string } }
      responses:
        '200':
          description: Capacity by day and window.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/Slot'
                        - type: object
                          properties:
                            zone: { type: string, examples: [LS-North] }
                            capacity: { type: integer, examples: [24] }
                            booked: { type: integer, examples: [19] }
  /routes:
    get:
      tags: [Routes]
      operationId: listRoutes
      summary: List routes for a day
      parameters:
        - { name: date, in: query, required: true, schema: { type: string, format: date } }
        - { name: depot, in: query, schema: { type: string } }
      responses:
        '200':
          description: The day's routes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Route' } }
                  unassigned: { type: array, items: { type: string }, description: 'Order IDs not on any route.' }
  /routes/optimise:
    post:
      tags: [Routes]
      operationId: optimiseRoutes
      summary: Optimise routes
      description: |
        Builds or rebuilds routes for a day. Keeps every booked window, van capacity (m³ and kg) and crew size.
        Returns a job; the result lists the proposed routes. Nothing reaches drivers until you publish.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [date, depot]
              properties:
                date: { type: string, format: date }
                depot: { type: string }
                vehicles: { type: array, items: { type: string }, description: 'Vehicles to use. Default is every available vehicle.' }
                objective: { type: string, enum: [fewest_miles, fewest_vehicles, balanced], default: balanced }
                lock_routes: { type: array, items: { type: string }, description: 'Routes to leave untouched.' }
            example: { date: '2026-10-14', depot: dep_leeds, objective: balanced }
      responses:
        '202':
          description: Optimisation started.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Job' }
  /routes/{route_id}:
    parameters:
      - { name: route_id, in: path, required: true, schema: { type: string, examples: [rte_V04x] } }
    get:
      tags: [Routes]
      operationId: getRoute
      summary: Retrieve a route
      responses:
        '200':
          description: The route with its stops in order.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Route' }
    patch:
      tags: [Routes]
      operationId: updateRoute
      summary: Reorder or reassign stops
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                vehicle: { type: string }
                driver: { type: string }
                stops: { type: array, items: { type: string }, description: 'Order IDs in the new sequence.' }
      responses:
        '200':
          description: The updated route, re-timed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Route' }
        '422':
          description: The change would break a booked window or van capacity.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /routes/{route_id}/publish:
    parameters:
      - { name: route_id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Routes]
      operationId: publishRoute
      summary: Publish to the driver
      description: Sends the route to the driver app as a run and texts recipients their narrowed arrival window.
      responses:
        '200':
          description: The run created from this route.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Run' }
  /agent/suggestions:
    get:
      tags: [Route agent]
      operationId: listSuggestions
      summary: List route agent suggestions
      parameters:
        - { name: date, in: query, schema: { type: string, format: date } }
        - { name: status, in: query, schema: { type: string, enum: [open, accepted, dismissed, expired] } }
      responses:
        '200':
          description: Suggestions, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Suggestion' } }
  /agent/suggestions/{suggestion_id}/accept:
    parameters:
      - { name: suggestion_id, in: path, required: true, schema: { type: string, examples: [sug_2Rk8] } }
    post:
      tags: [Route agent]
      operationId: acceptSuggestion
      summary: Accept a suggestion
      responses:
        '200':
          description: Applied. Affected runs are updated on drivers' phones.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Suggestion' }
        '409':
          description: The day has changed since the suggestion was made. Ask for a fresh one.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /agent/suggestions/{suggestion_id}/dismiss:
    parameters:
      - { name: suggestion_id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Route agent]
      operationId: dismissSuggestion
      summary: Dismiss a suggestion
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { reason: { type: string } } }
      responses:
        '200':
          description: Dismissed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Suggestion' }
  /jobs/{job_id}:
    parameters:
      - { name: job_id, in: path, required: true, schema: { type: string, examples: [job_5Hn2vQ] } }
    get:
      tags: [Jobs]
      operationId: getJob
      summary: Retrieve a job
      responses:
        '200':
          description: The job. `result` is set when `status` is `succeeded`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Job' }
  /runs:
    get:
      tags: [Runs]
      operationId: listRuns
      summary: List runs
      parameters:
        - { name: date, in: query, required: true, schema: { type: string, format: date } }
        - { name: driver, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [published, started, completed] } }
      responses:
        '200':
          description: Runs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Run' } }
  /runs/{run_id}/stops:
    parameters:
      - { name: run_id, in: path, required: true, schema: { type: string, examples: [run_Tu14V04] } }
    get:
      tags: [Runs]
      operationId: listStops
      summary: List a run's stops
      responses:
        '200':
          description: Stops in order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Stop' } }
  /runs/{run_id}/stops/{stop_id}/complete:
    parameters:
      - { name: run_id, in: path, required: true, schema: { type: string } }
      - { name: stop_id, in: path, required: true, schema: { type: string, examples: [stp_03] } }
    post:
      tags: [Runs]
      operationId: completeStop
      summary: Mark a stop delivered
      description: Normally sent by the driver app. Use it for your own driver apps or back-office corrections.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                signed_by: { type: string }
                photo_ids: { type: array, items: { type: string } }
                pieces_delivered: { type: array, items: { type: string }, description: 'Piece barcodes.' }
                notes: { type: string }
      responses:
        '200':
          description: The completed stop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Stop' }
  /runs/{run_id}/stops/{stop_id}/fail:
    parameters:
      - { name: run_id, in: path, required: true, schema: { type: string } }
      - { name: stop_id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Runs]
      operationId: failStop
      summary: Log a failed attempt
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, enum: [no_answer, refused, wrong_item, damaged, no_access, other] }
                photo_ids: { type: array, items: { type: string } }
                rebook: { type: boolean, default: true, description: 'Send the recipient a rebooking request straight away.' }
                notes: { type: string }
      responses:
        '200':
          description: The failed stop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Stop' }
  /tracking/vehicles:
    get:
      tags: [Live tracking]
      operationId: listVehiclePositions
      summary: Live vehicle positions
      parameters:
        - { name: depot, in: query, schema: { type: string } }
      responses:
        '200':
          description: The latest position of every vehicle on a run today.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        vehicle: { type: string, examples: [veh_04] }
                        run: { type: string }
                        latitude: { type: number, examples: [53.8201] }
                        longitude: { type: number, examples: [-1.5794] }
                        heading: { type: number, examples: [270] }
                        speed_mph: { type: number, examples: [24] }
                        next_stop: { type: string }
                        running_late_minutes: { type: integer, examples: [0] }
                        updated_at: { type: string, format: date-time }
  /vehicles:
    get:
      tags: [Vehicles]
      operationId: listVehicles
      summary: List vehicles
      responses:
        '200':
          description: Vehicles.
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Vehicle' } } } }
    post:
      tags: [Vehicles]
      operationId: createVehicle
      summary: Add a vehicle
      description: Adding a vehicle can change your bill. See your plan's included vans.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Vehicle' }
      responses:
        '201':
          description: Vehicle added.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Vehicle' }
  /drivers:
    get:
      tags: [Drivers]
      operationId: listDrivers
      summary: List drivers
      responses:
        '200':
          description: Drivers.
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Driver' } } } }
    post:
      tags: [Drivers]
      operationId: createDriver
      summary: Add a driver
      description: The driver is texted a link to install the app and set their PIN.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Driver' }
      responses:
        '201':
          description: Driver added.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Driver' }
  /depots:
    get:
      tags: [Depots]
      operationId: listDepots
      summary: List depots
      responses:
        '200':
          description: Depots.
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Depot' } } } }
  /shippers:
    get:
      tags: [Shippers]
      operationId: listShippers
      summary: List shippers
      responses:
        '200':
          description: Shippers.
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Shipper' } } } }
    post:
      tags: [Shippers]
      operationId: createShipper
      summary: Add a shipper
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Shipper' }
            example: { name: Oakly Home Ltd, contact_email: ops@oaklyhome.example, portal_users: [ops@oaklyhome.example] }
      responses:
        '201':
          description: Shipper added. Portal invites are emailed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Shipper' }
  /shippers/{shipper_id}/api-keys:
    parameters:
      - { name: shipper_id, in: path, required: true, schema: { type: string, examples: [shp_Oak1] } }
    post:
      tags: [Shippers]
      operationId: createShipperKey
      summary: Issue a Customer API key
      description: The full key is returned once. Share it with the shipper securely.
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { mode: { type: string, enum: [live, sandbox], default: sandbox }, label: { type: string } } }
      responses:
        '201':
          description: Key created.
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, key: { type: string, examples: [qd_test_4f9a0c1b2d3e5f60718293a4] }, mode: { type: string } } }
  /webhook-endpoints:
    get:
      tags: [Webhook endpoints]
      operationId: listAdminWebhookEndpoints
      summary: List webhook endpoints
      responses:
        '200':
          description: Endpoints.
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/WebhookEndpoint' } } } }
    post:
      tags: [Webhook endpoints]
      operationId: createAdminWebhookEndpoint
      summary: Add a webhook endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { $ref: '#/components/schemas/AdminEventType' } }
      responses:
        '201':
          description: Created. `secret` is shown once.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookEndpoint'
                  - type: object
                    properties: { secret: { type: string } }
webhooks:
  order.scanned:
    post:
      summary: Order fully scanned at the depot
      requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/AdminWebhookEvent' } } } }
      responses: { '200': { description: 'Acknowledged.' } }
  booking.reply_needs_review:
    post:
      summary: A reply needs a person
      description: Sent when a recipient reply could not be booked with confidence.
      requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/AdminWebhookEvent' } } } }
      responses: { '200': { description: 'Acknowledged.' } }
  job.completed:
    post:
      summary: Optimisation finished
      requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/AdminWebhookEvent' } } } }
      responses: { '200': { description: 'Acknowledged.' } }
  agent.suggestion_created:
    post:
      summary: New route agent suggestion
      requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/AdminWebhookEvent' } } } }
      responses: { '200': { description: 'Acknowledged.' } }
  run.started:
    post:
      summary: Driver started a run
      requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/AdminWebhookEvent' } } } }
      responses: { '200': { description: 'Acknowledged.' } }
  stop.failed:
    post:
      summary: Delivery attempt failed
      requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/AdminWebhookEvent' } } } }
      responses: { '200': { description: 'Acknowledged.' } }
  run.completed:
    post:
      summary: Driver finished a run
      requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/AdminWebhookEvent' } } } }
      responses: { '200': { description: 'Acknowledged.' } }
components:
  securitySchemes:
    adminKey:
      type: http
      scheme: bearer
      bearerFormat: Admin API key (qd_live_adm_… or qd_test_adm_…)
  parameters:
    OrderId:
      name: order_id
      in: path
      required: true
      schema: { type: string, examples: [ord_8Kq2Lm] }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: A unique string so retries never create duplicates. Remembered for 24 hours.
      schema: { type: string, maxLength: 64 }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    StartingAfter:
      name: starting_after
      in: query
      schema: { type: string }
  responses:
    Unauthorized:
      description: Missing or invalid key.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Forbidden:
      description: The key does not have the scope needed, or your plan does not include write access.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: insufficient_scope, message: This key needs the routes:write scope, request_id: req_1v9c3m7x2k0q } }
    NotFound:
      description: Not found.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Conflict:
      description: Conflicts with the current state.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unprocessable:
      description: A value was not accepted.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
            param: { type: string }
            request_id: { type: string }
    Address:
      type: object
      required: [line1, town, postcode]
      properties:
        line1: { type: string }
        line2: { type: string }
        town: { type: string }
        postcode: { type: string, examples: [LS6 2AB] }
        country: { type: string, default: GB }
        latitude: { type: number, readOnly: true }
        longitude: { type: number, readOnly: true }
    Recipient:
      type: object
      properties:
        name: { type: string }
        phone: { type: string }
        email: { type: string, format: email }
        address: { $ref: '#/components/schemas/Address' }
    Item:
      type: object
      properties:
        sku: { type: string }
        description: { type: string }
        pieces: { type: integer, minimum: 1 }
        weight_kg: { type: number }
        volume_m3: { type: number }
    Service:
      type: object
      properties:
        crew: { type: integer, enum: [1, 2] }
        assembly: { type: boolean }
        remove_packaging: { type: boolean }
        disposal: { type: boolean }
    Slot:
      type: object
      properties:
        date: { type: string, format: date, examples: ['2026-10-16'] }
        from: { type: string, examples: ['12:00'] }
        to: { type: string, examples: ['16:00'] }
    OrderStatus:
      type: string
      enum: [awaiting_scan, part_scanned, scanned, awaiting_reply, booked, planned, out_for_delivery, delivered, failed_attempt, on_hold, cancelled, returned]
    OrderInput:
      type: object
      required: [reference, recipient, items]
      properties:
        reference: { type: string }
        recipient: { $ref: '#/components/schemas/Recipient' }
        items: { type: array, items: { $ref: '#/components/schemas/Item' } }
        service: { $ref: '#/components/schemas/Service' }
        notes: { type: string }
    Order:
      type: object
      properties:
        id: { type: string, examples: [ord_8Kq2Lm] }
        object: { type: string, const: order }
        reference: { type: string, examples: [OAK-48213] }
        shipper: { type: object, properties: { id: { type: string, examples: [shp_Oak1] }, name: { type: string, examples: [Oakly Home Ltd] } } }
        depot: { type: string, examples: [dep_leeds] }
        status: { $ref: '#/components/schemas/OrderStatus' }
        recipient: { $ref: '#/components/schemas/Recipient' }
        items: { type: array, items: { $ref: '#/components/schemas/Item' } }
        pieces: { type: object, properties: { expected: { type: integer }, scanned: { type: integer } } }
        service: { $ref: '#/components/schemas/Service' }
        slot: { oneOf: [{ $ref: '#/components/schemas/Slot' }, { type: 'null' }] }
        route: { type: [string, 'null'], examples: [rte_V04x] }
        tracking_url: { type: [string, 'null'], format: uri }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Piece:
      type: object
      properties:
        id: { type: string }
        barcode: { type: string, examples: [QD8KQ2LM0102] }
        number: { type: string, examples: [2 of 3] }
        status: { type: string, enum: [awaiting_scan, at_depot, on_van, delivered, missing, damaged] }
        last_scan: { type: [object, 'null'], properties: { type: { type: string }, at: { type: string, format: date-time }, depot: { type: string }, vehicle: { type: string } } }
    ScanInput:
      type: object
      required: [barcode, type]
      properties:
        barcode: { type: string }
        type: { type: string, enum: [depot_in, load_to_van, unload, damaged, missing] }
        depot: { type: string }
        vehicle: { type: string, description: 'Required for `load_to_van`.' }
        at: { type: string, format: date-time, description: 'Defaults to now.' }
        photo_ids: { type: array, items: { type: string } }
    Reply:
      type: object
      properties:
        id: { type: string, examples: [rpl_7Wc2] }
        order: { type: string }
        channel: { type: string, enum: [sms, email] }
        text: { type: string, examples: [can u do thurs after 12] }
        understood:
          type: object
          properties:
            intent: { type: string, enum: [accept_offer, propose_date, decline_dates, question, call_request, unclear] }
            slot: { $ref: '#/components/schemas/Slot' }
            confidence: { type: number, examples: [0.94] }
        outcome: { type: string, enum: [booked, needs_review, resolved, dismissed] }
        received_at: { type: string, format: date-time }
    Stop:
      type: object
      properties:
        id: { type: string, examples: [stp_03] }
        sequence: { type: integer, examples: [3] }
        order: { type: string }
        type: { type: string, enum: [delivery, collection] }
        address: { $ref: '#/components/schemas/Address' }
        window: { type: object, properties: { from: { type: string }, to: { type: string } } }
        eta: { type: string, examples: ['09:26'] }
        status: { type: string, enum: [pending, arrived, completed, failed, skipped] }
        pieces: { type: integer }
        failure_reason: { type: [string, 'null'] }
        completed_at: { type: [string, 'null'], format: date-time }
    Route:
      type: object
      properties:
        id: { type: string, examples: [rte_V04x] }
        date: { type: string, format: date }
        depot: { type: string }
        vehicle: { type: string, examples: [veh_04] }
        driver: { type: [string, 'null'] }
        status: { type: string, enum: [draft, published] }
        stops: { type: array, items: { $ref: '#/components/schemas/Stop' } }
        totals:
          type: object
          properties:
            stops: { type: integer, examples: [18] }
            miles: { type: number, examples: [46.2] }
            drive_minutes: { type: integer, examples: [212] }
            load_percent: { type: number, examples: [78] }
            returns_at: { type: string, examples: ['16:40'] }
    Run:
      type: object
      properties:
        id: { type: string, examples: [run_Tu14V04] }
        route: { type: string }
        driver: { type: string }
        vehicle: { type: string }
        status: { type: string, enum: [published, started, completed] }
        started_at: { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        progress: { type: object, properties: { completed: { type: integer }, failed: { type: integer }, total: { type: integer } } }
    Suggestion:
      type: object
      properties:
        id: { type: string, examples: [sug_2Rk8] }
        status: { type: string, enum: [open, accepted, dismissed, expired] }
        trigger: { type: string, enum: [overnight_plan, breakdown, failed_stop, rebooking, capacity, running_late], examples: [capacity] }
        summary: { type: string, examples: ['Move stops 4 and 5 (LS16) from Van 03 to Van 01'] }
        reason: { type: string, examples: ['Van 03 is at 96% load. Van 01 passes within half a mile at 13:10, inside both windows.'] }
        impact: { type: object, properties: { miles_saved: { type: number, examples: [6] }, windows_kept: { type: boolean }, routes_changed: { type: array, items: { type: string } } } }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
    Job:
      type: object
      properties:
        id: { type: string, examples: [job_5Hn2vQ] }
        type: { type: string, const: route_optimisation }
        status: { type: string, enum: [queued, running, succeeded, failed] }
        progress: { type: number, examples: [0.6] }
        result:
          type: [object, 'null']
          properties:
            routes: { type: array, items: { $ref: '#/components/schemas/Route' } }
            unassigned: { type: array, items: { type: string } }
            miles_saved: { type: number }
        created_at: { type: string, format: date-time }
    Vehicle:
      type: object
      required: [name]
      properties:
        id: { type: string, readOnly: true, examples: [veh_04] }
        name: { type: string, examples: [Van 04] }
        registration: { type: string, examples: [YD73 KLP] }
        capacity_m3: { type: number, examples: [19.8] }
        capacity_kg: { type: number, examples: [1200] }
        crew: { type: integer, enum: [1, 2] }
        depot: { type: string }
        active: { type: boolean, default: true }
    Driver:
      type: object
      required: [name, phone]
      properties:
        id: { type: string, readOnly: true, examples: [drv_jamal] }
        name: { type: string, examples: [Jamal Hussain] }
        phone: { type: string }
        depot: { type: string }
        app_status: { type: string, readOnly: true, enum: [invited, active, suspended] }
    Depot:
      type: object
      properties:
        id: { type: string, examples: [dep_leeds] }
        name: { type: string, examples: [Leeds depot] }
        address: { $ref: '#/components/schemas/Address' }
        opening: { type: object, properties: { from: { type: string, examples: ['06:00'] }, to: { type: string, examples: ['19:00'] } } }
    Shipper:
      type: object
      required: [name]
      properties:
        id: { type: string, readOnly: true, examples: [shp_Oak1] }
        name: { type: string }
        contact_email: { type: string, format: email }
        portal_users: { type: array, items: { type: string, format: email } }
        booking: { type: object, properties: { automatic: { type: boolean, default: true }, sender_name: { type: string, examples: [OaklyHome] } } }
    AdminEventType:
      type: string
      enum: [order.created, order.scanned, order.booked, booking.reply_needs_review, job.completed, agent.suggestion_created, run.started, stop.completed, stop.failed, run.completed]
    WebhookEndpoint:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events: { type: array, items: { $ref: '#/components/schemas/AdminEventType' } }
        status: { type: string, enum: [enabled, disabled] }
    AdminWebhookEvent:
      type: object
      properties:
        id: { type: string, examples: [evt_9Lp2] }
        type: { $ref: '#/components/schemas/AdminEventType' }
        created: { type: string, format: date-time }
        data: { type: object, description: 'The order, run, stop, job or suggestion the event is about.' }
