openapi: 3.1.0
info:
  title: Carrier Connect TMS API
  version: 1.0.0
  description: |
    The Carrier Connect TMS API lets load boards, broker platforms and other software
    create loads in a carrier's TMS, read them, update their status, and receive
    webhooks when anything changes.

    **Authentication.** The carrier creates an API key in the TMS under
    Company → API & Webhooks. Send it as a Bearer token:
    `Authorization: Bearer cctms_live_...` (the `x-api-key` header also works).
    Each key belongs to one carrier company and only sees that company's loads.

    **Sandbox.** Keys starting with `cctms_test_` use the sandbox: same base URL,
    endpoints, validation and response shapes, plus `"sandbox": true`. Nothing is saved
    and no webhooks are sent. `GET /loads` returns three example loads (booked,
    picked_up and invoiced); status changes work on them, and the invoiced one returns
    `409` so you can test that error.

    **Rate limits.** 60 requests per minute and 10,000 per day per key. Exceeding
    them returns `429` with a `Retry-After` header (seconds).

    **Errors.** Every error has the shape
    `{ "error": { "code": "...", "message": "..." } }`. Validation errors add
    `fields: [{ field, message }]`.
  contact:
    name: Carrier Connect
    email: info@carrier-connect.center
    url: https://tms.carrier-connect.center/developers
servers:
  - url: https://tms.carrier-connect.center/api/v1
security:
  - bearerAuth: []
tags:
  - name: Account
  - name: Loads

paths:
  /me:
    get:
      tags: [Account]
      summary: Check your API key
      operationId: getMe
      responses:
        '200':
          description: The key works.
          content:
            application/json:
              schema:
                type: object
                properties:
                  company:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      name: { type: string, example: 'Sunshine Car Carriers LLC' }
                  environment: { type: string, enum: [production, sandbox] }
                  rate_limits:
                    type: object
                    properties:
                      per_minute: { type: integer, example: 60 }
                      per_day: { type: integer, example: 10000 }
              example:
                company: { id: 3f1c2a9e-7b1d-4c55-9a1e-2d0c5e8b7a10, name: Sunshine Car Carriers LLC }
                environment: production
                rate_limits: { per_minute: 60, per_day: 10000 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/AccountInactive' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /loads:
    get:
      tags: [Loads]
      summary: List loads
      description: Ordered by last update, newest first. If `next_cursor` is not null, pass it as `cursor` to get the next page.
      operationId: listLoads
      parameters:
        - { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
        - { name: updated_since, in: query, description: ISO 8601 date-time., schema: { type: string, format: date-time, example: '2026-10-01T00:00:00Z' } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: cursor, in: query, description: The `next_cursor` of the previous page., schema: { type: string } }
      responses:
        '200':
          description: A page of loads.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Load' } }
                  next_cursor: { type: [string, 'null'] }
                  sandbox: { type: boolean, description: Only present with a sandbox key. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/AccountInactive' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Loads]
      summary: Create a load
      description: |
        Send `external_id` with your own load ID: sending the same one again (a retry)
        returns the existing load with `200` instead of a duplicate. A new load returns
        `201` and starts as `booked`. The customer is matched by MC (or name) or created.
      operationId: createLoad
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewLoad' }
            example:
              external_id: LB-48213
              reference: PO 55120
              rate: 1850
              miles: 1195
              equipment: Car hauler
              notes: Call 1h before pickup
              customer: { name: Acme Logistics, type: broker, mc: '987654', email: ops@acme.com, phone: 305-555-1234 }
              stops:
                - { type: pickup, name: Dealer A, address: 1 Main St, city: Miami, state: FL, zip: '33174', date: '2026-10-01', time_from: '08:00', time_to: '12:00' }
                - { type: delivery, city: Houston, state: TX, zip: '77001', date: '2026-10-03' }
              vehicles:
                - { vin: 4T1B11HK5KU123456, year: 2019, make: Toyota, model: Camry, operable: true }
      responses:
        '201':
          description: Load created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoadCreated' }
        '200':
          description: A load with this `external_id` already existed; it is returned unchanged.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoadCreated' }
        '400': { $ref: '#/components/responses/InvalidJson' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/AccountInactive' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /loads/{id}:
    get:
      tags: [Loads]
      summary: Get a load
      operationId: getLoad
      parameters:
        - { $ref: '#/components/parameters/LoadId' }
      responses:
        '200':
          description: The load.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Load' }
                  sandbox: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/AccountInactive' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /loads/{id}/status:
    post:
      tags: [Loads]
      summary: Update a load's status
      description: |
        Allowed: `picked_up`, `delivered`, `cancelled`. Invoiced, paid and cancelled loads
        can't change (`409 invalid_transition`). Sending the current status again returns
        `"changed": false`. Loads that came from the Carrier Connect Load Board can't be
        cancelled here (`409 cancellation_requires_authorization`): the cancellation is
        requested on the Load Board and the broker authorizes it.
      operationId: updateLoadStatus
      parameters:
        - { $ref: '#/components/parameters/LoadId' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [picked_up, delivered, cancelled] }
            example: { status: delivered }
      responses:
        '200':
          description: Status updated (or unchanged).
          content:
            application/json:
              schema:
                type: object
                required: [data, changed]
                properties:
                  data: { $ref: '#/components/schemas/Load' }
                  changed: { type: boolean }
                  sandbox: { type: boolean }
        '400': { $ref: '#/components/responses/InvalidJson' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/AccountInactive' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The status change isn't allowed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                invoiced:
                  value: { error: { code: invalid_transition, message: "The load is already invoiced and can't change status." } }
                loadBoard:
                  value: { error: { code: cancellation_requires_authorization, message: "This load came from the Carrier Connect Load Board, so it can't be cancelled here. Open it on the Load Board and request the cancellation — the broker has to authorize it." } }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

webhooks:
  loadEvent:
    post:
      summary: Event delivered to your webhook URL
      description: |
        The carrier adds your HTTPS URL under Company → API & Webhooks. Events:
        `load.created`, `load.status_changed` (whether the change came from the TMS, the
        driver, the Carrier Connect Load Board or the API) and `ping` (the "Send test"
        button). Answer with any `2xx` within 10 seconds; otherwise we retry after 1, 5,
        15, 60, 180 and 360 minutes. Use `id` to ignore duplicates.

        Verify each request: `X-CCTMS-Signature: t=<unix>,v1=<hex>` where
        `v1 = HMAC-SHA256(signing_secret, "<t>.<raw body>")`. Reject requests older than
        5 minutes.
      parameters:
        - { name: X-CCTMS-Event, in: header, required: true, schema: { type: string, enum: [load.created, load.status_changed, ping] } }
        - { name: X-CCTMS-Signature, in: header, required: true, schema: { type: string, example: 't=1791234567,v1=5f2b9c…' } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [id, type, created_at, data]
              properties:
                id: { type: string, example: evt_381 }
                type: { type: string, enum: [load.created, load.status_changed, ping] }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  properties:
                    previous_status: { $ref: '#/components/schemas/Status' }
                    load: { $ref: '#/components/schemas/Load' }
      responses:
        '200': { description: Received. Any 2xx works. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: 'API key: `cctms_live_...` (production) or `cctms_test_...` (sandbox).'

  parameters:
    LoadId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    InvalidJson:
      description: The body isn't valid JSON, or `Content-Type` isn't `application/json`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: invalid_json, message: 'Send a JSON body with Content-Type: application/json.' } }
    Unauthorized:
      description: Missing, invalid or revoked API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: unauthorized, message: Invalid or revoked API key. } }
    AccountInactive:
      description: The carrier's TMS account is not active.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: account_inactive, message: This Carrier Connect TMS account is not active. Contact Carrier Connect. } }
    NotFound:
      description: Load not found, or it belongs to another company.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: not_found, message: Load not found. } }
    ValidationError:
      description: Some fields or query parameters are invalid.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: validation_error
              message: Some fields are invalid.
              fields: [ { field: 'stops[0].state', message: 'Use a 2-letter US state (e.g. TX).' } ]
    RateLimited:
      description: Too many requests. Wait the seconds in `Retry-After`.
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds to wait. }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: rate_limited, message: 'Too many requests. Limit: 60/minute, 10000/day.' } }
    ServerError:
      description: Something failed on our side. Retry later (creating a load with `external_id` is safe to retry).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: server_error, message: Something went wrong. Try again. } }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: validation_error }
            message: { type: string }
            fields:
              type: array
              items:
                type: object
                properties:
                  field: { type: string, example: 'stops[0].state' }
                  message: { type: string }

    Status:
      type: string
      description: booked → dispatched → picked_up → delivered → invoiced → paid, or cancelled.
      enum: [booked, dispatched, picked_up, delivered, invoiced, paid, cancelled]

    Customer:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 2, maxLength: 160, example: Acme Logistics }
        type: { type: string, enum: [broker, shipper], default: broker }
        mc: { type: string, description: Up to 8 digits (an MC- prefix is accepted)., example: '987654' }
        usdot: { type: string, description: Up to 8 digits. }
        contact_name: { type: string, maxLength: 120 }
        email: { type: string, format: email, maxLength: 200 }
        phone: { type: string, maxLength: 30 }

    NewStop:
      type: object
      required: [type, city, state]
      properties:
        type: { type: string, enum: [pickup, delivery] }
        name: { type: string, maxLength: 160 }
        address: { type: string, maxLength: 300 }
        city: { type: string, maxLength: 120, example: Miami }
        state: { type: string, description: 2-letter US state., example: FL }
        zip: { type: string, description: 5-digit ZIP (ZIP+4 accepted)., example: '33174' }
        date: { type: string, format: date, example: '2026-10-01' }
        time_from: { type: string, description: 'HH:MM, 24h.', example: '08:00' }
        time_to: { type: string, description: 'HH:MM, 24h.', example: '12:00' }
        reference: { type: string, maxLength: 100 }
        contact: { type: string, maxLength: 120 }
        notes: { type: string, maxLength: 1000 }

    NewVehicle:
      type: object
      properties:
        vin: { type: string, description: 17 characters., example: 4T1B11HK5KU123456 }
        year: { type: integer, minimum: 1900, maximum: 2100, example: 2019 }
        make: { type: string, maxLength: 60, example: Toyota }
        model: { type: string, maxLength: 60, example: Camry }
        color: { type: string, maxLength: 40 }
        operable: { type: boolean, default: true }

    NewLoad:
      type: object
      required: [rate, stops]
      properties:
        external_id: { type: string, maxLength: 100, description: Your own load ID. Makes the request safe to retry., example: LB-48213 }
        reference: { type: string, maxLength: 100, example: PO 55120 }
        rate: { type: number, minimum: 0, maximum: 10000000, example: 1850 }
        miles: { type: integer, minimum: 0, maximum: 20000, example: 1195 }
        load_type: { type: string, enum: [general, vehicles, ltl], description: Defaults to vehicles if you send vehicles, otherwise general. }
        equipment: { type: string, maxLength: 60, example: Car hauler }
        commodity: { type: string, maxLength: 160 }
        weight_lb: { type: integer, minimum: 0, maximum: 200000 }
        pieces: { type: integer, minimum: 0, maximum: 100000 }
        temperature_f: { type: number, minimum: -100, maximum: 200 }
        notes: { type: string, maxLength: 4000 }
        customer: { $ref: '#/components/schemas/Customer' }
        stops:
          type: array
          minItems: 2
          maxItems: 20
          description: At least one pickup and one delivery, in order.
          items: { $ref: '#/components/schemas/NewStop' }
        vehicles:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/NewVehicle' }

    Load:
      type: object
      properties:
        id: { type: string, format: uuid }
        number: { type: integer, example: 1042 }
        external_id: { type: [string, 'null'], example: LB-48213 }
        reference: { type: [string, 'null'], example: PO 55120 }
        status: { $ref: '#/components/schemas/Status' }
        load_type: { type: string, enum: [general, vehicles, ltl] }
        equipment: { type: [string, 'null'] }
        commodity: { type: [string, 'null'] }
        weight_lb: { type: [integer, 'null'] }
        pieces: { type: [integer, 'null'] }
        temperature_f: { type: [number, 'null'] }
        rate: { type: number, example: 1850 }
        miles: { type: [integer, 'null'], example: 1195 }
        notes: { type: [string, 'null'] }
        source: { type: string, enum: [api, tms, carrier_connect_load_board] }
        customer:
          type: [object, 'null']
          properties:
            name: { type: string }
            type: { type: string, enum: [broker, shipper] }
            mc: { type: [string, 'null'] }
            usdot: { type: [string, 'null'] }
        stops:
          type: array
          items:
            type: object
            properties:
              type: { type: string, enum: [pickup, delivery] }
              name: { type: [string, 'null'] }
              address: { type: [string, 'null'] }
              city: { type: string }
              state: { type: string }
              zip: { type: [string, 'null'] }
              date: { type: [string, 'null'], format: date }
              time_from: { type: [string, 'null'] }
              time_to: { type: [string, 'null'] }
              reference: { type: [string, 'null'] }
        vehicles:
          type: array
          items:
            type: object
            properties:
              vin: { type: [string, 'null'] }
              year: { type: [integer, 'null'] }
              make: { type: [string, 'null'] }
              model: { type: [string, 'null'] }
              color: { type: [string, 'null'] }
              operable: { type: boolean }
        picked_up_at: { type: [string, 'null'], format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    LoadCreated:
      type: object
      required: [data, created]
      properties:
        data: { $ref: '#/components/schemas/Load' }
        created: { type: boolean, description: false when an existing load with the same external_id was returned. }
        sandbox: { type: boolean }
