openapi: 3.0.3
info:
  title: Grindzero Public HTTP API
  description: |
    **Production API host:** `https://api.grindzero.app`

    - **Liveness:** `GET /api/health` returns JSON `{ "status": "ok" }`, no authentication.
    - **Versioned catalog:** routes under `/api/v1/*` require an organization API key.
      Use header `Authorization: Bearer <key>` or `X-Api-Key: <key>`.
      Production integration keys use the `gz_app_` prefix.

    Rate limiting (when enabled): about **100 successful requests per API key per 60-second sliding window**
    on protected `/api/v1/*` routes; exceeded → **429** with `code: rate_limited`.
    All `/api/v1/*` responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers.

    Human-oriented HTML docs: `/docs` on the API or Admin hostname for this deployment.

    Deeper design (Finnish + internals): `docs/API-DESIGN.md` in the **gz-admin-app** repository.
  version: 1.0.0
  license:
    name: Proprietary
    url: https://grindzero.app

servers:
  - url: https://api.grindzero.app
    description: Production API host
  - url: https://admin.grindzero.app
    description: Admin app (same deployment; `/api/*` routes are shared)

tags:
  - name: Health
    description: Deployment liveness (outside `/api/v1/*` gate)
  - name: Organizations
    description: Organization catalog (v1 MVP)
  - name: Sites
    description: Worksites belonging to the API key's organization (TECH-85)
  - name: Surpluses
    description: Surplus orders scoped to the organization's sites (TECH-85)
  - name: Receiving Points
    description: Receiving points belonging to the organization's sites (TECH-85)
  - name: Reports
    description: Lightweight summary reports (TECH-85)
  - name: Trucks
    description: Organization trucks — list and create (TECH-139)
  - name: Members
    description: Active organization members (TECH-139)
  - name: Webhooks
    description: |
      Outbound webhook events (TECH-138). Grindzero sends HTTP POST requests to your
      registered URL when a surplus is delivered and the waybill exists in Supabase Storage.
      Configure per-organization by a superadmin. Verification: HMAC-SHA256 (default) or
      bearer token (`x-gz-delivery-token`). v1 event: `surplus.delivered` only.

paths:
  /api/health:
    get:
      tags: [Health]
      summary: Liveness probe
      description: |
        Returns HTTP **200** with JSON `{ "status": "ok" }`.
        No authentication. Optional `Authorization` / `X-Api-Key` headers are ignored.
        Monitored by Sentry Uptime (TECH-41).
      operationId: getApiHealth
      security: []
      responses:
        '200':
          description: JSON liveness response
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  /api/v1/orgs:
    get:
      tags: [Organizations]
      summary: List organizations for the API key
      description: |
        Returns organizations the caller's API key may access.
        With a v1 organization-scoped key, `data` typically contains one row; the array shape allows future expansion.
        Requires scope `read:orgs` or `*`.
      operationId: getApiV1Orgs
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      responses:
        '200':
          description: Success
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrganizationListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenError'
        '404':
          $ref: '#/components/responses/OrgNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/orgs/{orgId}:
    patch:
      tags: [Organizations]
      summary: Update organization display name
      description: |
        Updates `name` for the organization. Path `orgId` must equal the organization UUID bound to the API key.
        Requires scope `write:orgs:self` or `*`.
      operationId: patchApiV1OrgById
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      parameters:
        - name: orgId
          in: path
          required: true
          description: Organization UUID (RFC 4122); must match the key's organization.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchOrganizationBody'
      responses:
        '200':
          description: Updated organization row
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrganizationPatchResponse'
        '400':
          description: |
            `invalid_org_id` — path is not a valid UUID.
            `invalid_json` — body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            `org_id_mismatch` — path org does not match the key's organization.
            `feature_not_enabled` — public API features not active for the org.
            `insufficient_scope` — key lacks required scope.
            `public_api_disabled` — versioned catalog temporarily unavailable (deployment).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
        '404':
          $ref: '#/components/responses/OrgNotFound'
        '422':
          description: |
            `validation_error` — `name` missing, wrong type, empty after trim, or over 500 characters.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/sites:
    get:
      tags: [Sites]
      summary: List worksites for the organization
      description: |
        Returns worksites owned by the API key's organization.
        Requires scope `read:data` or `*`.
      operationId: getApiV1Sites
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      parameters:
        - name: status
          in: query
          required: false
          description: Filter by status. `active` (default) excludes archived sites; `all` includes them.
          schema:
            type: string
            enum: [active, all]
            default: active
      responses:
        '200':
          description: Success
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SiteListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/surpluses:
    get:
      tags: [Surpluses]
      summary: List surplus orders for the organization
      description: |
        Returns surplus orders originating from the API key's organization's sites.
        Uses keyset (cursor-based) pagination. Requires scope `read:data` or `*`.
      operationId: getApiV1Surpluses
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      parameters:
        - name: status
          in: query
          required: false
          description: |
            Filter by surplus status. `open` = new + in_progress, `delivered`, `expired` = rejected, `all` (default).
          schema:
            type: string
            enum: [open, delivered, expired, all]
            default: all
        - name: site_id
          in: query
          required: false
          description: Filter by worksite UUID.
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          required: false
          description: Max rows per page (1–100, default 50).
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from a previous response's `meta.next_cursor`.
          schema:
            type: string
      responses:
        '200':
          description: Success
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SurplusListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/receiving-points:
    get:
      tags: [Receiving Points]
      summary: List receiving points for the organization
      description: |
        Returns receiving points belonging to the API key's organization's sites.
        Requires scope `read:data` or `*`.
      operationId: getApiV1ReceivingPoints
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      parameters:
        - name: status
          in: query
          required: false
          description: Filter by status. `active` (default) excludes archived; `all` includes them.
          schema:
            type: string
            enum: [active, all]
            default: active
      responses:
        '200':
          description: Success
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReceivingPointListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/reports/summary:
    get:
      tags: [Reports]
      summary: Lightweight summary report
      description: |
        Aggregated summary of delivered surplus loads within a date range.
        Requires scope `read:data` or `*`.
        CO2 savings and km-saved are estimated using industry coefficients
        (7 kg CO₂/m³, 0.175 km/m³).
      operationId: getApiV1ReportsSummary
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      parameters:
        - name: from
          in: query
          required: false
          description: Start date (ISO `YYYY-MM-DD`). Default is 30 days ago.
          schema:
            type: string
            format: date
        - name: to
          in: query
          required: false
          description: End date (ISO `YYYY-MM-DD`). Default is today.
          schema:
            type: string
            format: date
      responses:
        '200':
          description: Success
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReportSummaryResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/trucks:
    get:
      tags: [Trucks]
      summary: List trucks for the organization
      description: |
        Returns active trucks belonging to the API key's organization
        (`org_id` or `settings.preserved_org_id` match).
        Requires scope `read:data` or `*`.
      operationId: getApiV1Trucks
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      responses:
        '200':
          description: Success
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TruckListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      tags: [Trucks]
      summary: Create a truck
      description: |
        Creates a truck for the API key's organization.
        Body `orgId` must equal the organization UUID bound to the key.
        Requires scope `write:data` or `*`.
      operationId: postApiV1Trucks
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TruckCreateRequest'
      responses:
        '201':
          description: Truck created
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TruckCreateResponse'
        '400':
          description: `invalid_json` — body is not valid JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            `org_id_mismatch` — body `orgId` does not match the key's organization.
            `feature_not_enabled`, `insufficient_scope`, `public_api_disabled`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
        '409':
          description: `license_conflict` — license plate already exists for this country.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
        '422':
          description: |
            `validation_error` — missing or invalid fields (e.g. capacity outside 0–100 m³).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/members:
    get:
      tags: [Members]
      summary: List active organization members
      description: |
        Returns active org members with profile fields.
        `id` is `profiles.id`. Requires scope `read:data` or `*`.
      operationId: getApiV1Members
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      responses:
        '200':
          description: Success
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MemberListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

components:
  headers:
    X-RateLimit-Limit:
      description: Maximum requests allowed per sliding window (e.g. 100).
      schema:
        type: integer
        example: 100
    X-RateLimit-Remaining:
      description: Requests remaining in the current window (0 when exhausted).
      schema:
        type: integer
        example: 97
    X-RateLimit-Reset:
      description: Unix timestamp (seconds) when the current window resets.
      schema:
        type: integer
        example: 1716710460

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Raw API key value after `Bearer ` (e.g. `gz_app_…`).
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Same raw key value as Bearer form.

  schemas:
    PublicApiError:
      type: object
      required: [error, message, code]
      properties:
        error:
          type: string
          description: Short HTTP-style title (e.g. `Unauthorized`).
        message:
          type: string
          description: Human-readable detail.
        code:
          type: string
          description: Machine-readable code (e.g. `missing_api_key`, `rate_limited`).

    Organization:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        status:
          type: string
          description: Organization lifecycle/status in Grindzero (string enum may evolve).
        created_at:
          type: string
          format: date-time

    OrganizationListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Organization'
        meta:
          type: object
          required: [count]
          properties:
            count:
              type: integer
              minimum: 0

    OrganizationPatchResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [id, name, updated_at]
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            updated_at:
              type: string
              format: date-time

    PatchOrganizationBody:
      type: object
      required: [name]
      additionalProperties: true
      description: v1 updates `name` only; other fields are ignored.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 500
          description: Display name; leading/trailing whitespace is trimmed; must not be empty after trim.

    Coordinates:
      type: object
      nullable: true
      properties:
        lat:
          type: number
          format: double
        lng:
          type: number
          format: double

    Site:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        status:
          type: string
          enum: [active, archived]
        city:
          type: string
          nullable: true
        coordinates:
          $ref: '#/components/schemas/Coordinates'
        created_at:
          type: string
          format: date-time

    SiteListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Site'
        meta:
          type: object
          required: [count]
          properties:
            count:
              type: integer
              minimum: 0

    Surplus:
      type: object
      properties:
        id:
          type: string
          format: uuid
        site_id:
          type: string
          format: uuid
          description: Worksite UUID (`from_site_id`).
        volume_m3:
          type: number
          nullable: true
          description: Surplus volume in cubic metres.
        status:
          type: string
          description: 'Surplus lifecycle status: `new`, `in_progress`, `delivered`, `rejected`.'
        created_at:
          type: string
          format: date-time
        delivered_at:
          type: string
          format: date-time
          nullable: true

    SurplusListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Surplus'
        meta:
          type: object
          required: [count, next_cursor]
          properties:
            count:
              type: integer
              minimum: 0
              description: Number of rows in this page.
            next_cursor:
              type: string
              nullable: true
              description: Opaque cursor for the next page, or `null` if no more rows.

    ReceivingPoint:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        site_id:
          type: string
          format: uuid
        coordinates:
          $ref: '#/components/schemas/Coordinates'
        capacity_m3:
          type: integer
          nullable: true
        is_public:
          type: boolean
        status:
          type: string
          enum: [active, archived]
        created_at:
          type: string
          format: date-time

    ReceivingPointListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ReceivingPoint'
        meta:
          type: object
          required: [count]
          properties:
            count:
              type: integer
              minimum: 0

    ReportSummary:
      type: object
      properties:
        period:
          type: object
          properties:
            from:
              type: string
              format: date
            to:
              type: string
              format: date
        loads_count:
          type: integer
          minimum: 0
        volume_m3:
          type: number
        co2_savings_kg:
          type: number
          description: Estimated CO₂ savings (kg) — coefficient 7 kg/m³.
        heavy_vehicle_km_saved:
          type: number
          description: Estimated heavy vehicle km saved — coefficient 0.175 km/m³.

    ReportSummaryResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/ReportSummary'

    Truck:
      type: object
      properties:
        id:
          type: string
          format: uuid
        license:
          type: string
        country:
          type: string
          description: ISO-style plate country code (e.g. `fi`).
        capacity_m3:
          type: number
          nullable: true
          description: Truck capacity in cubic metres (0–100).
        name:
          type: string
          nullable: true
        driver_id:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time

    TruckListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Truck'
        meta:
          type: object
          required: [count]
          properties:
            count:
              type: integer
              minimum: 0

    TruckCreateRequest:
      type: object
      required: [orgId, license, country]
      additionalProperties: true
      properties:
        orgId:
          type: string
          format: uuid
          description: Organization UUID; must match the API key's organization.
        license:
          type: string
        country:
          type: string
          description: Supported license plate country code (e.g. `fi`).
        capacity:
          type: number
          nullable: true
          minimum: 0
          maximum: 100
          description: Capacity in cubic metres.
        name:
          type: string
          nullable: true
        driverId:
          type: string
          format: uuid
          nullable: true
        factoryId:
          type: string
          format: uuid
          nullable: true
        notes:
          type: string
          nullable: true

    TruckCreateResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/Truck'

    Member:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Profile UUID (`profiles.id`).
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
          nullable: true
        role:
          type: string
          description: Operational role in the organization.
        phone:
          type: string
          nullable: true

    MemberListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Member'
        meta:
          type: object
          required: [count]
          properties:
            count:
              type: integer
              minimum: 0

    WebhookPayload:
      type: object
      required: [id, event, timestamp, subject, data]
      description: |
        Outbound webhook envelope sent as HTTP POST to the registered URL.
        Headers: `Content-Type`, `x-gz-event`, `x-gz-delivery-id` (same as `id`),
        plus `x-gz-signature` (HMAC mode) or `x-gz-delivery-token` (bearer mode).
        Full `data` schema: `docs/TECH-138-WEBHOOKS-V1.md`.
      properties:
        id:
          type: string
          format: uuid
          description: Delivery id for partner deduplication (`x-gz-delivery-id`).
        event:
          type: string
          description: Event type identifier (v1: `surplus.delivered`).
          example: surplus.delivered
        timestamp:
          type: string
          format: date-time
          description: ISO 8601 UTC timestamp of dispatch.
        subject:
          type: object
          required: [type, id]
          properties:
            type:
              type: string
              example: surplus
            id:
              type: string
              format: uuid
        data:
          $ref: '#/components/schemas/WebhookSurplusDeliveredData'
      example:
        id: 550e8400-e29b-41d4-a716-446655440000
        event: surplus.delivered
        timestamp: '2026-06-16T11:00:00.000Z'
        subject:
          type: surplus
          id: b56a5b19-41d1-42b3-ac23-e0a38bc2c150
        data:
          surplusId: b56a5b19-41d1-42b3-ac23-e0a38bc2c150
          orgId: 660e8400-e29b-41d4-a716-446655440001
          orgName: Grindzero Oy
          status: delivered
          previousStatus: in_progress
          volume_m3: 12
          waybill:
            storagePath: surpluses/b56a5b19-41d1-42b3-ac23-e0a38bc2c150/1710000000.jpg
            url: https://example.com/signed
            urlExpiresAt: '2026-06-16T12:00:00.000Z'
          adminUrl: https://admin.grindzero.app/surpluses/b56a5b19-41d1-42b3-ac23-e0a38bc2c150

    WebhookSurplusDeliveredData:
      type: object
      description: |
        Payload `data` for `surplus.delivered` — enriched snapshot at delivery time.
        Nullable fields are omitted logically as `null` when unknown.
      properties:
        surplusId:
          type: string
          format: uuid
        orgId:
          type: string
          format: uuid
          nullable: true
        orgName:
          type: string
          nullable: true
        fromSiteId:
          type: string
          format: uuid
          nullable: true
        fromSiteName:
          type: string
          nullable: true
        fromSiteAddress:
          type: object
          nullable: true
          properties:
            street:
              type: string
              nullable: true
            postalCode:
              type: string
              nullable: true
            city:
              type: string
              nullable: true
            countryCode:
              type: string
              nullable: true
        fromSiteCoordinates:
          type: object
          nullable: true
          properties:
            lat:
              type: number
            lng:
              type: number
        toReceivingPointId:
          type: string
          format: uuid
          nullable: true
        toReceivingPointName:
          type: string
          nullable: true
        toLabel:
          type: string
          nullable: true
        truckId:
          type: string
          format: uuid
          nullable: true
        truckLabel:
          type: string
          nullable: true
        truckLicense:
          type: string
          nullable: true
        truckCountry:
          type: string
          nullable: true
        truckCapacity_m3:
          type: number
          nullable: true
        truckOrgId:
          type: string
          format: uuid
          nullable: true
        truckOrgName:
          type: string
          nullable: true
        driverId:
          type: string
          format: uuid
          nullable: true
        driverLabel:
          type: string
          nullable: true
        createdById:
          type: string
          format: uuid
          nullable: true
        createdByLabel:
          type: string
          nullable: true
        originContactLabel:
          type: string
          nullable: true
        receivingContactLabel:
          type: string
          nullable: true
        volume_m3:
          type: number
          nullable: true
        status:
          type: string
          description: Always `delivered`.
        previousStatus:
          type: string
          nullable: true
          description: Prior status from surplus_log when available.
        additive:
          type: boolean
        additiveBilledTo:
          type: string
          nullable: true
        materialGrade:
          type: string
          nullable: true
        manufacturer:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time
          nullable: true
        updatedAt:
          type: string
          format: date-time
          nullable: true
        deliveredAt:
          type: string
          format: date-time
          nullable: true
        deliveredById:
          type: string
          format: uuid
          nullable: true
        deliveredByLabel:
          type: string
          nullable: true
        waybill:
          type: object
          properties:
            storagePath:
              type: string
            url:
              type: string
              description: Signed Supabase Storage URL (time-limited).
            urlExpiresAt:
              type: string
              format: date-time
        fieldCapture:
          type: object
          nullable: true
          properties:
            haulierLabel:
              type: string
              nullable: true
            driverName:
              type: string
              nullable: true
            driverPhone:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
        adminUrl:
          type: string
          format: uri
          nullable: true

    WebhookSurplusCreatedData:
      type: object
      deprecated: true
      description: Deprecated TECH-57 MVP — use WebhookSurplusDeliveredData.
      properties:
        surplusId:
          type: string
          format: uuid
        orgId:
          type: string
          format: uuid
        siteId:
          type: string
          format: uuid
          description: Worksite UUID.
        volume_m3:
          type: number
          nullable: true
        status:
          type: string
          description: Initial surplus status (typically `new`).

  responses:
    Unauthorized:
      description: Missing or invalid API key, or key expired.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          examples:
            missing:
              value:
                error: Unauthorized
                message: Missing or malformed API key
                code: missing_api_key
    ForbiddenError:
      description: |
        Authenticated or valid transport but not allowed (various `code` values), including
        `feature_not_enabled`, `insufficient_scope`, `public_api_disabled`, and `org_id_mismatch` (PATCH path).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
    OrgNotFound:
      description: Organization not found or soft-deleted.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          examples:
            default:
              value:
                error: Not Found
                message: Organization not found
                code: org_not_found
    RateLimited:
      description: Per-key rate limit exceeded.
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          examples:
            default:
              value:
                error: Too Many Requests
                message: Rate limit exceeded
                code: rate_limited
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          examples:
            default:
              value:
                error: Internal Server Error
                message: Unexpected error
                code: internal_error
