openapi: 3.1.0
info:
  title: Offload Public API
  description: Public push-data-into-Offload + identity API. Stable for 12 months from v1 launch. See https://docs.letsoffload.com/developers for the full developer guide.
  version: "1.0"
servers:
  - url: https://api.letsoffload.com
    description: Production
security:
  - integration-api-key: []
tags:
  - name: public-bulk-import
    description: |-
      The Bulk Import API lets your scheduling, ERP, or broker system push CSV/XLSX files of records into Offload without anyone touching a browser. The same backend powers the in-app upload wizard and this public surface, so what your integration sees is what a dispatcher sees.

      The loop is the same on every push: POST a file, receive a 202 with a status URL, poll until terminal, walk per-row results. That's the whole integration.

      ## Entity model

      ```
      Organization
        └─ Project
             └─ Load
                  └─ Import Run         (one per file you POST)
                       └─ Import Row    (one per data row in the file)
      ```

      - **Organization** is your company tenant. Tokens are bound to one organization at issuance; cross-org access is not possible.
      - **Project** groups related loads (a job, a campaign, a customer engagement).
      - **Load** is a single shipment with its routes, permits, and assets.
      - **Import Run** records one POST. It tracks the file, the parser output, the column mapping, and the per-row outcomes.
      - **Import Row** is a single data row from your file with its validation result, the natural keys we echoed back, and any errors.

      ## Wire conventions

      These are the rules every Bulk Import endpoint follows. They are part of the v1 contract and won't change without a v2 bump.

      - **URL prefix.** All endpoints under `/v1/imports/`. The server applies a global `/api` prefix, so the wire path is `/api/v1/imports/...`.
      - **Auth.** `Authorization: Bearer osk_live_...`.
      - **State enum.** `QUEUED | RUNNING | SUCCEEDED | PARTIAL | SKIPPED | FAILED | CANCELLED` (UPPER_SNAKE_CASE per Google AIP-216).
      - **Resource envelopes.** Every resource carries `id`, `object`, `createdAt`. Lists carry `object: "list"`, `data`, `nextPageUrl`.
      - **Error envelope.** `application/problem+json` per RFC 7807, with the Offload-specific extensions `code`, `requestId`, `docUrl`.
      - **Reserved header.** `Idempotency-Key` is reserved on the Bulk Import endpoints; any non-empty value is rejected with `400 IDEMPOTENCY_KEY_RESERVED`. Per-row diff (params hash + per-row `SELECT FOR UPDATE`) prevents duplicate writes on safe retries, so the header isn't needed for correctness.
      - **Initial poll interval.** The `202` response sets `Retry-After: 5`.

      ## First push vs automated push

      The first time we see a column shape we haven't mapped for your organization yet, the response stages the run instead of committing. The `state` is `QUEUED` and a `nextAction` field carries a deep link into the in-app review:

      ```json
      {
        "id": "8c4f2e10-3b7d-4f9e-9a1c-7e8b0d4f2a91",
        "state": "QUEUED",
        "nextAction": {
          "type": "review_in_app",
          "url": "https://app.letsoffload.com/imports/jobs/8c4f2e10-3b7d-4f9e-9a1c-7e8b0d4f2a91/mapping",
          "expiresAt": "2026-05-22T18:00:00Z"
        }
      }
      ```

      Your org admin opens that URL, confirms the mapping, and commits. From that point on, every push of the same column shape runs unattended — clean rows commit, errors skip into the `failed` bucket, warnings flag into `warnings`.

      ## Polling & lifecycle

      Every POST returns `202 Accepted` and a `Location` header pointing at a status URL. Your integration polls that URL until the run reaches a terminal state, then walks the per-row results.

      **The basic loop:**

      1. POST the file. Read the `Location` header.
      2. Wait 5 seconds.
      3. GET the status URL with the previous `ETag` in `If-None-Match`.
      4. If `state` is terminal, you're done. Otherwise, wait longer (exponential backoff to a 30-second ceiling) and go to step 3.
      5. When terminal, walk `results.failed`, `results.warnings`, `results.succeeded`, `results.skipped` as needed.

      **Intervals and backoff:**

      - **Initial delay: 5 seconds.** The server's `Retry-After` header on the initial 202 advises this floor. Going faster won't get you results any sooner; the server runs the same fixed work-loop regardless of how often you poll.
      - **Backoff: exponential, double per poll.** 5s, 10s, 20s, then cap at 30s. After roughly 75 seconds you're polling at the ceiling.
      - **Ceiling: 30 seconds.** Polling slower is fine; polling faster than the floor wastes bandwidth on both sides.
      - **Use `If-None-Match`.** Every status response includes an `ETag`. Send it back as `If-None-Match` on the next poll; the server returns `304 Not Modified` with no body if nothing changed. Saves you parsing the body and saves Offload serializing it.

      **Terminal vs non-terminal states.** The wire enum has seven values, two non-terminal and five terminal:

      | State | Terminal? | Meaning |
      |---|---|---|
      | `QUEUED` | No | Row created, processing not yet started OR staged for in-app review. Keep polling. |
      | `RUNNING` | No | Processing in progress. Keep polling. |
      | `SUCCEEDED` | Yes | Every row landed cleanly. At least one record created or updated. |
      | `PARTIAL` | Yes | Some rows succeeded, some failed or warned. Walk `results.failed` to see why. |
      | `SKIPPED` | Yes | Terminal, but no row changed state — every row was a no-op duplicate. Lets your script branch on "quiet push" vs "busy push". |
      | `FAILED` | Yes | Run could not produce per-row results. Envelope or parse error, or the run was stuck and reaped, or staging expired. Inspect the run-level `error.code`. |
      | `CANCELLED` | Yes | An Offload operator aborted the run. |

      Stop polling as soon as `state` is in `{ SUCCEEDED, PARTIAL, SKIPPED, FAILED, CANCELLED }`.

      ## Dry runs

      A header turns any push into a dry run: the full pipeline runs, validation produces per-row outputs, but nothing is written.

      ```
      Offload-Dry-Run: true
      ```

      A dry-run response has `dryRun: true` at the run level and the same body shape a live run produces. Useful for CI smoke tests on every push, validating a new column shape end-to-end before pointing your cron at the live endpoint, and confirming an upstream schema change still produces clean rows.

      Dry-run preset-misses do not emit `nextAction` (saving the mapping would itself be a side effect). Dry runs are retained for 7 days, then pruned.

      ## Concurrency

      Offload protects against accidental concurrent pushes of the same file. File fingerprint is `sha256(kind + sha256(file) + byteLength)` — the inner file SHA is also persisted on the run row as `source_sha256` so the concurrency guard and the audit trail share one value. If a run for the same organization and fingerprint is still `QUEUED` or `RUNNING`, a second push returns `409 IMPORT_IN_PROGRESS` with a pointer to the existing run. The right response is to poll `existingRunUrl` instead of retrying the upload.

      ## Retries

      For transient errors (`5xx`, `429`), use jittered exponential backoff. For deterministic errors (`4xx` other than `429`), do not retry — they will fail the same way every time. Fix the upstream and re-push.

      ## Full Python integration loop

      ```python
      import os
      import time

      import requests

      BASE = os.environ["OFFLOAD_API_BASE"]
      TOKEN = os.environ["OFFLOAD_API_TOKEN"]
      HEADERS = {"Authorization": f"Bearer {TOKEN}"}

      TERMINAL = {"SUCCEEDED", "PARTIAL", "SKIPPED", "FAILED", "CANCELLED"}
      INITIAL_DELAY_S = 5
      MAX_DELAY_S = 30


      def push_jobs(csv_path, *, dry_run=False):
          headers = dict(HEADERS)
          if dry_run:
              headers["Offload-Dry-Run"] = "true"
          with open(csv_path, "rb") as fp:
              response = requests.post(
                  f"{BASE}/v1/imports/jobs",
                  headers=headers,
                  files={"file": (csv_path, fp, "text/csv")},
              )
          response.raise_for_status()
          status_url = f"{BASE}{response.headers['Location']}"
          return poll_until_terminal(status_url)


      def poll_until_terminal(status_url):
          delay = INITIAL_DELAY_S
          etag = None
          while True:
              headers = dict(HEADERS)
              if etag:
                  headers["If-None-Match"] = etag
              response = requests.get(status_url, headers=headers)
              if response.status_code == 304:
                  time.sleep(delay)
                  delay = min(delay * 2, MAX_DELAY_S)
                  continue
              response.raise_for_status()
              etag = response.headers.get("ETag")
              run = response.json()
              if run.get("nextAction"):
                  return run
              if run["state"] in TERMINAL:
                  return run
              time.sleep(delay)
              delay = min(delay * 2, MAX_DELAY_S)
      ```

      ## Kinds

      The `{kind}` path segment on `POST /v1/imports/{kind}` tells Offload how to interpret the file you're sending. Each kind has its own field set, its own required-field rules, and its own per-row result shape. The wire envelope (status responses, error codes, polling) is identical across kinds.

      | Kind | What it imports | Required fields |
      |---|---|---|
      | `jobs` | Loads + projects. Each row becomes one Load, attached to a Project (created on the fly if the `jobCode` is new). | `jobCode`, `loadNo` |

      More kinds will land as additive endpoints; new kinds never change the wire shape of existing ones. If you have a use case that needs a kind we haven't shipped yet, email support@letsoffload.com.

      ### Per-row outputs (`jobs`)

      A `jobs` row lands in one of four buckets after the run terminates:

      - `succeeded` — the Load was created or updated. The `Imported` badge appears on it in the Offload UI.
      - `warnings` — the Load committed, but something needed normalization (unit conversion, address fuzzy-match). Inspect to confirm the normalization matches your intent.
      - `failed` — validation rejected the row. The response carries one or more `errors[]` entries with stable codes (see the error table at the bottom of the API Reference page).
      - `skipped` — the row was a no-op duplicate (same `loadNo` + same content as a prior import).

      ### Sample request

      ```bash
      curl -X POST "$OFFLOAD_API_BASE/v1/imports/jobs" \
        -H "Authorization: Bearer $OFFLOAD_API_TOKEN" \
        -F "file=@loads.csv;type=text/csv"
      ```

      ### Sample CSV

      ```csv
      jobCode,loadNo,commodity,weight,projectOriginAddress,projectDestinationAddress,pickupDate
      P-100,J-001,Transformer,82000,"123 Main St, Houston, TX 77002","500 Elm St, Dallas, TX 75201",2026-06-01
      P-100,J-002,Transformer,76000,"123 Main St, Houston, TX 77002","20 Boston Ave, Tulsa, OK 74103",2026-06-01
      P-101,J-003,Generator,68000,"800 Riverside Dr, Memphis, TN 38103","100 Broadway, Nashville, TN 37203",2026-06-02
      ```

      Your CSV headers don't have to match field IDs literally — the parser tolerates natural-language synonyms (e.g. `pickup`, `from`, `pickup address`, `origin` all map to the project pickup address). The first push of a new column shape is staged for a one-time in-app mapping confirmation; every push of the same shape afterward auto-commits.

      ## Mappable fields (`jobs`)

      The Bulk Import API is "bring your column names." The first time you push a new column shape, Offload's mapper proposes how each of your headers lines up against the target schema, and your team confirms it once in the in-app wizard. Every subsequent push of the same shape commits unattended.

      You do not need to rename your columns to match the IDs below — the mapper handles synonyms. The IDs are how Offload labels the fields internally and what your team will see in the mapping confirmation UI.

      ### Project fields

      Resolved or upserted once per unique `jobCode` in the file. Lookup fields match against existing Companies or People by exact name or email; a miss leaves the field unassigned with a warning rather than creating a new record from the CSV.

      | Field ID | Display name | Required | Type | Notes |
      |---|---|---|---|---|
      | `jobCode` | Project ID / Job code | Yes | string | Used to group rows into one Project. Matches an existing Project by ID; otherwise creates a new one. |
      | `projectDescription` | Project description | | string | Free-text description shown on the project card. |
      | `customerName` | Customer (lookup) | | string | Exact-name match against existing customer Companies. |
      | `branchName` | Branch (lookup) | | string | Exact-name match against existing branch Companies. |
      | `projectManagerEmail` | Project Manager email (lookup) | | email | Exact-email match against existing People. |

      **Project pickup address.** Use either the single-column form OR the structured sub-fields, not both. When both are populated, the structured fields win.

      | Field ID | Display name | Type |
      |---|---|---|
      | `projectOriginAddress` | Pickup address (single column) | string |
      | `projectOriginAddressLine1` | Pickup: address line 1 | string |
      | `projectOriginAddressLine2` | Pickup: address line 2 | string |
      | `projectOriginCity` | Pickup: city | string |
      | `projectOriginState` | Pickup: state (2-letter or full) | string |
      | `projectOriginZip` | Pickup: ZIP | string |

      **Project dropoff / job-site address.** Same rule: single-column OR structured.

      | Field ID | Display name | Type |
      |---|---|---|
      | `projectDestinationAddress` | Dropoff address (single column) | string |
      | `projectDestinationAddressLine1` | Dropoff: address line 1 | string |
      | `projectDestinationAddressLine2` | Dropoff: address line 2 | string |
      | `projectDestinationCity` | Dropoff: city | string |
      | `projectDestinationState` | Dropoff: state (2-letter or full) | string |
      | `projectDestinationZip` | Dropoff: ZIP | string |

      ### Load fields

      | Field ID | Display name | Required | Type | Notes |
      |---|---|---|---|---|
      | `loadNo` | Load number | Yes | string | Sequential load identifier within a project. Natural key for upsert. |
      | `commodity` | Commodity name | | string | What is being hauled. Creates a Commodity row on the load. |
      | `weight` | Loaded weight (lb) | | number | Gross weight of the load in pounds. |
      | `width` | Loaded width (ft) | | number | Loaded envelope width. |
      | `length` | Loaded length (ft) | | number | Loaded envelope length. |
      | `height` | Loaded height (ft) | | number | Loaded envelope height. |
      | `commodityWeight` | Commodity weight (lb) | | number | Just-the-commodity weight (without trailer). |
      | `commodityWidth` | Commodity width (ft) | | number | Just-the-commodity width. |
      | `commodityLength` | Commodity length (ft) | | number | Just-the-commodity length. |
      | `commodityHeight` | Commodity height (ft) | | number | Just-the-commodity height. |
      | `overhangFront` | Front overhang (ft) | | number | Overhang past the front of the trailer. |
      | `overhangRear` | Rear overhang (ft) | | number | Overhang past the back of the trailer. |
      | `groundClearance` | Ground clearance (in) | | number | Inches. |
      | `pickupDate` | Pickup date | | date | When the load is picked up. ISO 8601 (`YYYY-MM-DD`). |
      | `dropoffDate` | Dropoff date | | date | When the load is delivered. ISO 8601 (`YYYY-MM-DD`). |
      | `notes` | Notes | | string | Free-text notes attached to the load. |

      **Per-load pickup address override.** Optional. Only set these when a specific load's pickup differs from the project's pickup. Single-column OR structured.

      | Field ID | Display name | Type |
      |---|---|---|
      | `loadOriginAddress` | Load pickup address (single column) | string |
      | `loadOriginAddressLine1` | Load pickup: address line 1 | string |
      | `loadOriginAddressLine2` | Load pickup: address line 2 | string |
      | `loadOriginCity` | Load pickup: city | string |
      | `loadOriginState` | Load pickup: state | string |
      | `loadOriginZip` | Load pickup: ZIP | string |

      **Per-load dropoff address override.** Optional. Only set these when a specific load's dropoff differs from the project's dropoff. Single-column OR structured.

      | Field ID | Display name | Type |
      |---|---|---|
      | `loadDestinationAddress` | Load dropoff address (single column) | string |
      | `loadDestinationAddressLine1` | Load dropoff: address line 1 | string |
      | `loadDestinationAddressLine2` | Load dropoff: address line 2 | string |
      | `loadDestinationCity` | Load dropoff: city | string |
      | `loadDestinationState` | Load dropoff: state | string |
      | `loadDestinationZip` | Load dropoff: ZIP | string |

      **Natural keys on results.** The `source` block returned on per-row results echoes back the natural keys from your file so you can correlate without counting from row 0:

      ```json
      {
        "rowIndex": 4,
        "source": {
          "loadNo": "J-001",
          "jobCode": "P-100",
          "projectName": "Houston → Dallas spring rebuild"
        }
      }
      ```

      `projectName` is included whenever your file carries a project-description column the mapper recognizes. Reading any one of the three is enough to find the row in your source data.

      **Future kinds.** Additional kinds will publish their own field tables here as they ship. The wire envelope around the file is identical across kinds; only the target schema changes.
  - name: public-identity
    description: |-
      `GET /v1/whoami` — the cross-cutting token-verification probe.

      Returns `200` with the calling token's key prefix, status, and the organization the token is bound to. Returns `401` with the RFC 7807 problem envelope (`code: UNAUTHORIZED`) if the token is missing, malformed, unknown, revoked, or expired.

      Use this as the integration deploy smoke-test on any system that calls any Offload Public API. Bound to its own tag (not Bulk Import) because identity is a portal-wide concern, not a bulk-import operation.

      The one-line `curl` example is in the [Quickstart](https://docs.letsoffload.com/developers/quickstart).
paths:
  /api/v1/imports/{kind}:
    post:
      operationId: create
      tags:
        - public-bulk-import
      summary: Submit a bulk import for asynchronous processing.
      description: "Always returns HTTP 202 with a Location header pointing at the status URL. The customer polls that URL until `state` is terminal. Dry runs (Offload-Dry-Run: true) parse and validate without writing."
      parameters:
        - in: path
          name: kind
          required: true
          description: "Consumer kind. Only `jobs` is available today; posting any other kind returns `400 INVALID_KIND`. More kinds land additively as they ship."
          schema:
            type: string
        - in: header
          name: Offload-Dry-Run
          required: false
          description: When `true`, parse and validate without writing.
          schema:
            type: string
            enum:
              - "true"
              - "false"
        - in: header
          name: Idempotency-Key
          required: false
          description: >-
            Reserved header. v1 rejects any request that sets this header
            with `400 IDEMPOTENCY_KEY_RESERVED`. M1's row-level diff already
            guarantees data correctness on retries, so the value would not
            change behavior; rejecting loudly prevents clients from training
            on a contract that may shift when idempotency becomes meaningful
            in a later version. Do not send this header in v1.
          schema:
            type: string
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
              required:
                - file
      responses:
        "202":
          description: Run created. Body matches ImportPublicCreateResponse.
          headers:
            Location:
              schema:
                type: string
              description: Status URL to poll.
            Retry-After:
              schema:
                type: integer
              description: Initial poll delay in seconds.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ImportPublicStatusResponse"
        "400":
          $ref: "#/components/responses/ProblemResponse"
        "401":
          $ref: "#/components/responses/ProblemResponse"
        "409":
          $ref: "#/components/responses/ProblemResponse"
        "413":
          $ref: "#/components/responses/ProblemResponse"
        "415":
          $ref: "#/components/responses/ProblemResponse"
  /api/v1/imports/runs/{transactionId}:
    get:
      operationId: getStatus
      tags:
        - public-bulk-import
      summary: Poll the state of a previously-submitted import.
      description: Returns the same body shape as POST /:kind. Sets an ETag header — send it back as If-None-Match to short-circuit unchanged responses with a 304.
      parameters:
        - in: path
          name: transactionId
          required: true
          schema:
            type: string
            format: uuid
        - in: header
          name: If-None-Match
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Current run state.
          headers:
            ETag:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ImportPublicStatusResponse"
        "304":
          description: No change since the supplied If-None-Match ETag.
        "401":
          $ref: "#/components/responses/ProblemResponse"
        "404":
          $ref: "#/components/responses/ProblemResponse"
  /api/v1/imports/runs/{transactionId}/results/{bucket}:
    get:
      operationId: getResults
      tags:
        - public-bulk-import
      summary: List per-row results for a run, bucketed by outcome.
      description: Buckets `succeeded`, `failed`, `warnings`, `skipped`. Cursor-paginated; page size capped at 250. Follow `nextPageUrl` to thread through.
      parameters:
        - in: path
          name: transactionId
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: bucket
          required: true
          schema:
            type: string
            enum:
              - succeeded
              - failed
              - warnings
              - skipped
        - in: query
          name: cursor
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Per-row results page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ImportPublicResultsResponse"
        "400":
          $ref: "#/components/responses/ProblemResponse"
        "401":
          $ref: "#/components/responses/ProblemResponse"
        "404":
          $ref: "#/components/responses/ProblemResponse"
  /api/v1/whoami:
    get:
      operationId: whoami
      tags:
        - public-identity
      summary: Verify a public API token and return its bound organization.
      description: Returns 200 with the calling token's key prefix, status, and the organization the token is bound to. Use this as the integration health check — if it returns 200, the token is live and the org binding is intact. Returns 401 with an RFC 7807 problem body if the token is missing, malformed, unknown, revoked, or expired.
      security:
        - integration-api-key: []
      responses:
        "200":
          description: PublicWhoamiResponse.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicWhoamiResponse"
        "401":
          $ref: "#/components/responses/ProblemResponse"
        "404":
          $ref: "#/components/responses/ProblemResponse"
components:
  securitySchemes:
    integration-api-key:
      type: http
      scheme: bearer
      bearerFormat: osk_live_<token>
      description: Public API token issued via Sysadmin → API Keys. One token works across every Offload Public API.
  responses:
    ProblemResponse:
      description: RFC 7807 problem+json envelope.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ImportPublicProblemDetails"
  schemas:
    ImportPublicCounts:
      type: object
      properties:
        total:
          type: integer
          minimum: 0
        succeeded:
          type: integer
          minimum: 0
        failed:
          type: integer
          minimum: 0
        warnings:
          type: integer
          minimum: 0
        skipped:
          type: integer
          minimum: 0
      required:
        - total
        - succeeded
        - failed
        - warnings
        - skipped
    ImportPublicResultsBuckets:
      type: object
      properties:
        succeeded:
          type: string
        failed:
          type: string
        warnings:
          type: string
        skipped:
          type: string
      required:
        - succeeded
        - failed
        - warnings
        - skipped
    ImportPublicNextAction:
      type: object
      properties:
        type:
          type: string
          enum:
            - review_in_app
        url:
          type: string
        expiresAt:
          type: string
          format: date-time
      required:
        - type
        - url
        - expiresAt
    ImportPublicRunError:
      type: object
      properties:
        code:
          type: string
        message:
          type: string
        docUrl:
          type: string
      required:
        - code
        - message
        - docUrl
    ImportPublicStatusResponse:
      type: object
      properties:
        id:
          type: string
        object:
          type: string
          enum:
            - import_run
        kind:
          type: string
        state:
          type: string
          enum:
            - QUEUED
            - RUNNING
            - SUCCEEDED
            - PARTIAL
            - SKIPPED
            - FAILED
            - CANCELLED
        dryRun:
          type: boolean
        createdAt:
          type: string
          format: date-time
        completedAt:
          oneOf:
            - type: string
              format: date-time
            - type: "null"
        counts:
          $ref: "#/components/schemas/ImportPublicCounts"
        results:
          $ref: "#/components/schemas/ImportPublicResultsBuckets"
        nextAction:
          oneOf:
            - $ref: "#/components/schemas/ImportPublicNextAction"
            - type: "null"
        error:
          oneOf:
            - $ref: "#/components/schemas/ImportPublicRunError"
            - type: "null"
      required:
        - id
        - object
        - kind
        - state
        - dryRun
        - createdAt
        - completedAt
        - counts
        - results
        - nextAction
        - error
    ImportPublicRowError:
      type: object
      properties:
        code:
          type: string
        field:
          type: string
        message:
          type: string
        docUrl:
          type: string
      required:
        - code
        - message
        - docUrl
    ImportPublicRowSource:
      type: object
      description: >-
        The customer-supplied natural keys for this row, echoed back so the
        integrator can locate it in their own file. Fields vary by import
        kind — for `jobs` you will see `loadNo`, `jobCode`, `projectName`.
      additionalProperties: true
    ImportPublicFailedRow:
      type: object
      properties:
        rowIndex:
          type: integer
          minimum: 0
        source:
          $ref: "#/components/schemas/ImportPublicRowSource"
        errors:
          type: array
          items:
            $ref: "#/components/schemas/ImportPublicRowError"
      required:
        - rowIndex
        - source
        - errors
    ImportPublicWarningRow:
      type: object
      properties:
        rowIndex:
          type: integer
          minimum: 0
        source:
          $ref: "#/components/schemas/ImportPublicRowSource"
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/ImportPublicRowError"
      required:
        - rowIndex
        - source
        - warnings
    ImportPublicSucceededRow:
      type: object
      properties:
        rowIndex:
          type: integer
          minimum: 0
        source:
          $ref: "#/components/schemas/ImportPublicRowSource"
        createdRecordIds:
          type: object
          additionalProperties:
            oneOf:
              - type: string
              - type: integer
      required:
        - rowIndex
        - source
        - createdRecordIds
    ImportPublicSkippedRow:
      type: object
      properties:
        rowIndex:
          type: integer
          minimum: 0
        source:
          $ref: "#/components/schemas/ImportPublicRowSource"
        reason:
          type: string
          enum:
            - unchanged
            - skipped_idempotent
            - skipped
      required:
        - rowIndex
        - source
        - reason
    ImportPublicResultsResponse:
      type: object
      properties:
        object:
          type: string
          enum:
            - list
        bucket:
          type: string
          enum:
            - succeeded
            - failed
            - warnings
            - skipped
        data:
          type: array
          description: >-
            The row shape inside `data` is the bucket-specific shape
            matching the `bucket` field on this response. `failed` →
            ImportPublicFailedRow, `warnings` → ImportPublicWarningRow,
            `succeeded` → ImportPublicSucceededRow, `skipped` →
            ImportPublicSkippedRow. Note: a row with a non-fatal warning
            that still committed appears in BOTH the `succeeded` and
            `warnings` buckets, so iterating both will see it twice.
          items:
            oneOf:
              - $ref: "#/components/schemas/ImportPublicFailedRow"
              - $ref: "#/components/schemas/ImportPublicWarningRow"
              - $ref: "#/components/schemas/ImportPublicSucceededRow"
              - $ref: "#/components/schemas/ImportPublicSkippedRow"
        nextPageUrl:
          oneOf:
            - type: string
            - type: "null"
      required:
        - object
        - bucket
        - data
        - nextPageUrl
    ImportPublicProblemDetails:
      type: object
      properties:
        type:
          type: string
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
        code:
          type: string
        requestId:
          type: string
        docUrl:
          type: string
      required:
        - type
        - title
        - status
        - detail
        - instance
        - code
        - requestId
        - docUrl
    PublicWhoamiResponse:
      type: object
      properties:
        id:
          type: string
        object:
          type: string
          enum:
            - whoami
        organization:
          type: object
          properties:
            id:
              type: integer
              minimum: 1
            name:
              type: string
          required:
            - id
            - name
        key:
          type: object
          properties:
            prefix:
              type: string
            status:
              type: string
              enum:
                - active
                - rotating
                - revoked
          required:
            - prefix
            - status
      required:
        - id
        - object
        - organization
        - key
