openapi: 3.1.0
info:
  title: Consigns DWT Gateway API
  version: "1.0.0"
  description: >
    Send waste receipts to Consigns and we file them to DEFRA's Digital Waste
    Tracking service under our approved-provider credential, then handle
    validation, retries, amendments and the compliance clock for you.


    Keep your own weighbridge / movement software as the system of record —
    POST each receipt here as it happens.


    ## Authentication

    Every request needs an API key as a bearer token:
    `Authorization: Bearer cg_live_...` (or `cg_sandbox_...`). Create and revoke
    keys in the Consigns portal under Settings → API keys. A **sandbox** key
    exercises the whole flow and returns a `WT-DRY-` tracking id without ever
    contacting DEFRA — integrate against it, then switch to a live key.


    Two identities are involved, deliberately separated: Consigns
    authenticates to DEFRA with **our** approved-provider credential (never
    shared, never in any request), and every filing is attributed to **your**
    organisation via your DEFRA organisation API code — set once in the
    Consigns portal (Settings → DEFRA), not sent per request. Live filing
    requires it; sandbox does not.


    ## Three outcomes on ingest

    - **201 Filed** — the receipt was valid and sent to DEFRA.

    - **202 Quarantined** — the receipt was *recorded* but not filed, because it
      is missing detail DEFRA requires. It is held with the statutory clock
      running; supply the missing fields (via `PUT`, or complete it in the
      Consigns portal) and it files. Your movement is never lost. The exact
      `blockingFields` are returned so you can fix them at source.

    - **422 Rejected** — the request wasn't a usable receipt at all (bad JSON,
      no reference, no waste items). Nothing was recorded.


    ## The minimum viable receipt is bigger than it looks

    Three requirements catch out every first integration:

    - `carrier.vehicleRegistration` — transport defaults to **road**, and road
      makes the vehicle registration mandatory. Omit it and every receipt
      quarantines.

    - `receiver` address — required.

    - `wasteItems[].containsHazardous` — send `false` explicitly for a
      non-hazardous load; an unknown-hazard load is treated as hazardous and
      then also needs hazard codes + an HWCN.
  contact:
    name: Consigns
    url: https://consigns.co.uk
  license:
    name: Proprietary — Consigns & Comply Ltd
    url: https://consigns.co.uk/terms

servers:
  - url: https://consigns.co.uk
    description: Production (live filing)
  - url: https://stage.consigns.co.uk
    description: Staging / sandbox

security:
  - bearerAuth: []

paths:
  /api/v1/dwt/receipts:
    get:
      operationId: listReceipts
      summary: List your receipts
      description: >
        Your organisation's API-submitted receipts, newest first. Built for
        reconciliation and for polling your quarantine queue from your own
        system. Follow `nextCursor` until it returns null.
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [not_ready, quarantined, sent, warning, amended, failed, outcome_unknown] }
        - name: since
          in: query
          description: Only receipts received at or after this time (ISO 8601).
          schema: { type: string, format: date-time }
        - name: until
          in: query
          description: Only receipts received before this time (ISO 8601).
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          description: The `nextCursor` from the previous page.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: A page of receipts.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReceiptListResult' }
        "401": { description: Auth failed. }
    post:
      operationId: createReceipt
      summary: Submit a waste receipt
      description: >
        Records a receipt and files it to DEFRA if valid. Requires an
        `Idempotency-Key` header so a retry after a timeout can never create a
        duplicate — reuse the same key + body to safely retry.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CanonicalDwtReceipt'
      responses:
        "201":
          description: Filed to DEFRA.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReceiptResult' }
        "202":
          description: Recorded but quarantined — needs more detail before it can file.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuarantineResult' }
        "400":
          description: Missing Idempotency-Key, or invalid JSON.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        "401":
          description: Missing, malformed, invalid or revoked API key.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        "422":
          description: >
            Not a usable receipt (no reference / no waste items), or the same
            Idempotency-Key was reused with a different body.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /api/v1/dwt/receipts/validate:
    post:
      operationId: validateReceipt
      summary: Pre-flight check a receipt
      description: >
        Runs a receipt through the exact validators the real endpoint uses —
        including your organisation's own defaults — and reports what would
        happen, WITHOUT recording or filing anything. Always answers 200 with
        a verdict (a shape problem is a verdict, not an error). Build against
        this until `wouldFile: true`, then switch to POST /receipts.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CanonicalDwtReceipt' }
      responses:
        "200":
          description: The verdict.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidateResult' }
        "401": { description: Auth failed. }
  /api/v1/dwt/reference/ewc:
    get:
      operationId: searchEwcCodes
      summary: Search the EWC catalogue
      description: >
        The European Waste Catalogue Consigns validates against. Drive your
        waste-code picker from here and a chosen code can never bounce at
        ingest. A digits query matches codes ("1302", "13 02 05"); anything
        else searches descriptions ("solvent").
      parameters:
        - name: query
          in: query
          schema: { type: string }
        - name: hazardous
          in: query
          schema: { type: boolean }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        "200":
          description: Matching codes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EwcSearchResult' }
        "401": { description: Auth failed. }
  /api/v1/dwt/reference/container-types:
    get:
      operationId: listContainerTypes
      summary: List DEFRA's container-type codes
      description: >
        The exact 3-letter codes DEFRA accepts for `containerType`. Free text
        is rejected — offer exactly this list in your form.
      responses:
        "200":
          description: The full list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContainerTypesResult' }
        "401": { description: Auth failed. }
  /api/v1/permits/balances:
    get:
      operationId: getPermitBalances
      summary: Read your permit balances
      description: >
        Every permit line with live figures — yearly intake against its limit
        and the tonnes currently on site — the same numbers as the Permit
        balancing page in Consigns. Requires a key created with the
        "permit balances" permission ticked; other keys get 403.
      responses:
        "200":
          description: The balances.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PermitBalancesResult' }
        "401": { description: Auth failed. }
        "403": { description: This key was created without the permit-balances permission. }
  /api/v1/dwt/receipts/{ref}:
    parameters:
      - name: ref
        in: path
        required: true
        description: Your `externalReference`, or the `submissionId` we returned.
        schema: { type: string }
    get:
      operationId: getReceipt
      summary: Read a receipt's status
      parameters:
        - name: include
          in: query
          required: false
          description: >
            What the response includes: `receipt` (status only), `note` (adds
            the rendered digital waste note), or `both`. Defaults to the API
            key's stored setting. This is also how you fetch the corrected
            note after an amendment.
          schema: { type: string, enum: [receipt, note, both] }
        - name: format
          in: query
          required: false
          description: Note delivery — `url` (signed, expires after an hour) or `base64`.
          schema: { type: string, enum: [url, base64], default: url }
      responses:
        "200":
          description: Current status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusResult' }
        "401":
          description: Auth failed.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        "404":
          description: No receipt for that reference in your organisation.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    put:
      operationId: amendReceipt
      summary: Correct a receipt
      description: >
        Updates the receipt in place and re-files it. A receipt that had already
        filed becomes `amended` (the original is kept in the audit trail); a
        quarantined receipt corrected to valid files fresh; a still-incomplete
        correction re-quarantines. No Idempotency-Key needed — a DEFRA amendment
        is idempotent.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CanonicalDwtReceipt' }
      responses:
        "200":
          description: Correction filed (status `amended` or `sent`).
          content: { application/json: { schema: { $ref: '#/components/schemas/ReceiptResult' } } }
        "202":
          description: Correction recorded but still quarantined.
          content: { application/json: { schema: { $ref: '#/components/schemas/QuarantineResult' } } }
        "401": { description: Auth failed. }
        "404": { description: No receipt for that reference. }
        "422": { description: Invalid receipt. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: An API key, e.g. `cg_live_...` or `cg_sandbox_...`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >
        A unique key per logical receipt. Retrying with the same key + body
        replays the original response and never double-files. The same key with
        a *different* body returns 422.
      schema: { type: string, maxLength: 255 }

  schemas:
    CanonicalDwtReceipt:
      type: object
      required: [externalReference, wasteItems]
      properties:
        externalReference:
          type: string
          description: Your own reference for this receipt. Also the key for status lookup.
        include:
          type: string
          enum: [receipt, note, both]
          description: >
            What the response includes. `note`/`both` add the rendered digital
            waste note (HWCN, WTN, SEPA or NIEA note — whichever the receipt
            resolves to) so your system can attach the legal document without
            scanning paper. Defaults to the API key's stored setting. NOTE: on
            an idempotent replay the note's signed URL may have expired —
            re-fetch it via GET with `?include=note`.
        noteFormat:
          type: string
          enum: [url, base64]
          default: url
          description: Note delivery — a signed URL that expires after an hour, or the PDF bytes inline.
        receivedAt:
          type: string
          format: date-time
          description: When the waste was received. Defaults to now.
        processDescription:
          type: string
          description: The process that gave rise to the waste. DEFRA-required.
        hazardousWasteConsignmentCode:
          type: string
          description: HWCN code, for a hazardous load (or give reasonForNoConsignmentCode).
        reasonForNoConsignmentCode:
          type: string
        producer:
          $ref: '#/components/schemas/Party'
        carrier:
          $ref: '#/components/schemas/Carrier'
        receiver:
          $ref: '#/components/schemas/Receiver'
        wasteItems:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/WasteItem' }
    WasteItem:
      type: object
      required: [ewcCodes, weightKg]
      properties:
        ewcCodes:
          type: array
          items: { type: string }
          example: ["17 05 04"]
        weightKg: { type: number }
        physicalForm: { type: string, description: "e.g. Solid, Liquid, Sludge. DEFRA-required." }
        containerType:
          type: string
          description: >
            One of DEFRA's exact 3-letter codes (DRU, IBC, TAN, SKI, ROR, BAG,
            BAL, BOX, CAN, CAR, CAS, CON, FIB, LOO, PAL, WBI). Free text is
            rejected. DEFRA-required.
        containerCount: { type: integer, description: DEFRA-required. }
        weightIsEstimate: { type: boolean }
        description: { type: string, description: A short description of the waste. DEFRA-required. }
        containsHazardous:
          type: boolean
          description: Send `false` explicitly for a non-hazardous load — unknown defaults to hazardous.
        hazardCodes:
          type: array
          items: { type: string }
        disposalOrRecoveryCodes:
          type: array
          items: { $ref: '#/components/schemas/DisposalCode' }
    DisposalCode:
      type: object
      required: [code]
      properties:
        code: { type: string, example: "R5" }
        weightKg: { type: number }
        weightIsEstimate: { type: boolean }
    Party:
      type: object
      description: Producer/consignor. name + addressLine1 required.
      properties:
        name: { type: string }
        company: { type: string }
        addressLine1: { type: string }
        addressLine2: { type: string }
        town: { type: string }
        county: { type: string }
        postcode: { type: string }
        email: { type: string }
        phone: { type: string }
    Carrier:
      allOf:
        - $ref: '#/components/schemas/Party'
        - type: object
          properties:
            registrationNumber: { type: string, description: Waste carrier registration (or give exemptionReason). }
            exemptionReason: { type: string }
            vehicleRegistration: { type: string, description: Required when transport is by road (the default). }
            modeOfTransport: { type: string, description: "Defaults to road." }
    Receiver:
      allOf:
        - $ref: '#/components/schemas/Party'
        - type: object
          properties:
            permitNumber: { type: string, description: The receiving site's environmental permit. DEFRA-required. }
    ReceiptResult:
      type: object
      properties:
        submissionId: { type: string, description: Our id for this receipt. }
        externalReference: { type: string }
        status: { type: string, enum: [sent, warning, amended, failed, accepted] }
        wasteTrackingId: { type: ["string", "null"], description: "DEFRA id; WT-DRY-... in sandbox." }
        environment: { type: string, enum: [live, sandbox] }
        idempotentReplay: { type: boolean, description: Present when this was a replayed response. }
        note:
          $ref: '#/components/schemas/NoteArtifact'
        errors:
          type: array
          items: { $ref: '#/components/schemas/BlockingField' }
    QuarantineResult:
      type: object
      properties:
        submissionId: { type: string }
        externalReference: { type: string }
        status: { type: string, enum: [quarantined] }
        blockingFields:
          type: array
          items: { $ref: '#/components/schemas/BlockingField' }
        note:
          $ref: '#/components/schemas/NoteArtifact'
        message: { type: string }
    StatusResult:
      type: object
      properties:
        submissionId: { type: string }
        externalReference: { type: string }
        status: { type: string, enum: [sent, warning, amended, failed, outcome_unknown, quarantined, accepted] }
        wasteTrackingId: { type: ["string", "null"] }
        submittedAt: { type: ["string", "null"] }
        receivedAt: { type: ["string", "null"] }
        warnings: { type: array, items: { type: object } }
        note:
          $ref: '#/components/schemas/NoteArtifact'
        blockingFields:
          type: array
          description: Present while quarantined — the fields still needed.
          items: { $ref: '#/components/schemas/BlockingField' }
    NoteArtifact:
      type: object
      description: >
        The rendered digital waste note (present when `include` asked for it).
        When `available` is false, `reason` says why: `quarantined` (nothing
        has been filed from this receipt — no note is issued) or
        `render_failed` (transient; fetch again shortly).
      properties:
        available: { type: boolean }
        documentKind:
          type: ["string", "null"]
          description: "`hwcn` (hazardous consignment note) or `wtn` (non-hazardous transfer note)."
        format: { type: string, enum: [url, base64] }
        url: { type: string, description: Signed download link (format `url`). }
        expiresAt: { type: string, format: date-time, description: When the signed link stops working (~1 hour). }
        data: { type: string, description: Base64 PDF bytes (format `base64`). }
        contentType: { type: string, enum: [application/pdf] }
        sha256:
          type: ["string", "null"]
          description: Hash of the archived snapshot — pin it to verify integrity.
        reason: { type: string, enum: [quarantined, render_failed], description: Present when available is false. }
        message: { type: string, description: Present when available is false. }
    BlockingField:
      type: object
      properties:
        field: { type: string }
        message: { type: string }
    ValidateResult:
      type: object
      properties:
        wouldFile: { type: boolean }
        outcome:
          type: string
          enum: [filed, quarantined, rejected]
          description: What the real endpoint would do with this exact payload.
        shapeErrors: { type: array, items: { type: string }, description: Present when the envelope itself is unusable. }
        coreMissing: { type: array, items: { type: string }, description: Present when required fields are missing. }
        blockingFields:
          type: array
          description: Present when the receipt would quarantine.
          items: { $ref: '#/components/schemas/BlockingField' }
        message: { type: string }
    ReceiptListResult:
      type: object
      properties:
        receipts:
          type: array
          items:
            type: object
            properties:
              submissionId: { type: string }
              externalReference: { type: ["string", "null"] }
              status: { type: string }
              wasteTrackingId: { type: ["string", "null"] }
              submittedAt: { type: ["string", "null"] }
              receivedAt: { type: ["string", "null"] }
              createdAt: { type: string }
        nextCursor:
          type: ["string", "null"]
          description: Pass as `cursor` for the next page; null = no more pages.
    EwcSearchResult:
      type: object
      properties:
        codes:
          type: array
          items:
            type: object
            properties:
              code: { type: string, example: "13 02 05*" }
              description: { type: string }
              hazardous: { type: boolean }
              chapter: { type: string }
        totalMatches: { type: integer }
    ContainerTypesResult:
      type: object
      properties:
        containerTypes:
          type: array
          items:
            type: object
            properties:
              code: { type: string, example: "ROR" }
              description: { type: string }
    PermitBalancesResult:
      type: object
      properties:
        balances:
          type: array
          items:
            type: object
            properties:
              site:
                type: object
                properties:
                  id: { type: string }
                  name: { type: string }
                  postcode: { type: ["string", "null"] }
              ewcCode: { type: ["string", "null"] }
              periodStartsOn: { type: string, format: date }
              yearlyLimitTonnes: { type: number }
              receivedThisYearTonnes: { type: number }
              remainingTonnes: { type: number }
              usagePct: { type: number }
              level: { type: string, enum: [ok, warn, over] }
              stock:
                type: object
                properties:
                  limitTonnes: { type: ["number", "null"] }
                  onSiteTonnes: { type: number }
                  openingTonnes: { type: number }
                  remainingTonnes: { type: ["number", "null"] }
                  level: { type: ["string", "null"], enum: [ok, warn, over, null] }
        generatedAt: { type: string, format: date-time }
    Error:
      type: object
      properties:
        error: { type: string }
        code: { type: string }
        fields:
          type: array
          items: { type: string }
