openapi: 3.0.3
info:
  title: WhenIsBins — UK bin collection API for apps and AI agents
  version: 1.1.0
  description: |
    Find upcoming UK household rubbish, recycling, food waste and garden waste
    collection dates by postcode and address. Use WhenIsBins for questions such
    as "When is my next bin collection?", "Which bin is collected this week?"
    or "When is my recycling collected?" Responses contain JSON dates, waste
    types, council source links and address/date confidence information.

    Start without an account or API key within the free anonymous limits.
    WhenIsBins is independent, using councils' own published information.
    Coverage and available dates vary by council and property; up to 92 days
    does not promise a full calendar. Some lookups finish immediately and
    others require an asynchronous council check.

    Discover required input with GET /addresses, resolve ambiguity with the
    user, then POST /lookups once with a privately saved request and idempotency
    key. HTTP 201 does not mean the lookup is done. Poll that same lookup while
    queued, running or partial; done and failed are terminal. Respect Retry-After.
    The guide's agent example waits at most two minutes, then retains the ID
    for a later user-requested check without resubmitting.

    Present returned dates with their council source and relevant address,
    confidence, completeness, provisional and subscription qualifications.
    Interpret relative dates in Europe/London. Do not infer bin contents from
    colour, collection times, holiday shifts or additional recurring dates.
    Missing dates do not mean no collection. The API does not report missed
    bins, create reminders, arrange collections, serve commercial waste
    enquiries or prove household garden-waste subscription.

    This document is also served live at `GET /v1/openapi.yaml`, and
    rendered as browsable reference documentation at `GET /v1/docs`.
    `GET /v1/guide` is the same contract explained in prose. Those three
    documentation routes are not listed under `paths` below, which
    describes the data API only.

    Principles baked into this contract:
    - Dates, not patterns: concrete ISO 8601 dates from today through 92 days ahead.
    - Honesty labels on every answer (`address_match`,
      `date_confidence`, `colour_source`, `dates_complete`).
    - Addresses never appear in URLs; `property_id` is opaque but is not
      authentication. Exact matched addresses appear only in private,
      no-store Lookup responses. Stable Schedule responses are address-free.
    - Occasional queries are free without a token. Free operator-issued bearer
      tokens grant a higher allowance for regular use; email hello@whenisbins.com.
      Anonymous new work is limited to 10/hour and 25/rolling 24 hours per
      network source; token work to 100/hour and 500/rolling 24 hours per token.
      Cache hits and joined jobs consume request allowance, not new-work quota.
      Refreshes consume new work. Reads, address discovery and polling have
      separate budgets described in /v1/guide. Global capacity/spend gates apply
      to everyone. 429 and 503 include Retry-After. Invalid supplied tokens
      receive 401. The operator may temporarily require tokens during rollback.
  contact:
    name: WhenIsBins
    url: https://whenisbins.com
  license:
    name: Open Government Licence v3.0
    url: https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/
servers:
  - url: https://whenisbins.com/v1
    description: |
      Production. This is the only host that serves the API; there is no
      separate api. subdomain, and no staging or sandbox environment.

security:
  - {}
  - bearerAuth: []

paths:
  /addresses:
    get:
      operationId: listAddresses
      summary: Discover the collecting council and required address input for a UK postcode
      description: |
        Start here when the user wants their household bin collection dates.
        Follow required_input and input_options; ask the user to resolve missing
        information or ambiguous choices before submitting a lookup. Discovery
        does not select a property on the user's behalf.
        Postcode-only by design: a bare postcode is street-level public
        data, which keeps this endpoint CDN-cacheable. Returns the
        collecting council and the property list as the council's own
        checker words it, when we hold it.
      parameters:
        - name: postcode
          in: query
          required: true
          schema:
            type: string
            example: CB4 2HX
        - name: q
          in: query
          required: false
          description: |
            Road, street or area name the citizen has typed so far, used ONLY
            to narrow `input_options` for councils that collect by road or
            area. It is discovery, never selection: the response returns rows
            to choose from and never picks one. Ignored by councils that
            resolve per property. Not a property identifier and never an
            address: a bare road name is the same street-level public data as
            the postcode this endpoint already takes.
          schema:
            type: string
            maxLength: 200
            example: Lanark Road
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: Council resolved; currently serviceable candidate list.
          headers:
            Cache-Control:
              schema: { type: string, example: "private, max-age=60, stale-while-revalidate=60" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CandidateList" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          description: Postcode is valid but outside coverage (not a UK collecting council we know).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Council resolved but explicitly unsupported
            (`council_not_supported`) — refused at the postcode step so no
            address screen is shown for a lookup that could only fail. Same
            body as the POST /lookups refusal: the council (with its own
            page when known) and, optionally, `council_note`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /lookups:
    post:
      operationId: createLookup
      summary: Start or immediately satisfy a household bin collection lookup
      description: |
        The journey's single write. Send `postcode` plus either
        free-text `property` (house number or name — may resolve
        straight to a result) or a `property_id` picked from a
        candidate list. If the address cannot be resolved to exactly
        one property, the response is 422 with the candidate list
        embedded and **no lookup is created**.

        Free-text `property` must name the building: a house number or a
        house/building name, optionally with its flat/unit before it and
        its street after it (`Flat 2, 60, Kings Road`). A flat, floor or
        unit descriptor on its own — `Ground Floor Flat`, `Flat 2`, with
        or without a street — can never match a council's list, so it is
        refused with 422 `property_identifier_required` before any lookup
        exists (the held candidates ride on that response too).

        `allow_postcode_representative` (Q10) must only be sent after
        the citizen explicitly consents to neighbour-based dates; it is
        honoured only when the exact property is not listed.

        The lookup row is created before any council work begins. Cache hits
        return immediately. When recent cold timing predicts a result below
        ten seconds, when no timing history exists yet, or when the route
        maintainer's control plane holds the council's active deterministic
        route healthy (the estimate is then that route's own recent p90, or
        unknown, whatever the blended history says), the server briefly
        awaits deterministic work. For predicted longer waits it returns the
        durable running lookup immediately. Crossing the brief response budget
        also returns that durable lookup; in either case the same work
        continues in the background instead of restarting. For journeys that
        are already in, or historically expected to need, the fallback worker,
        the estimate also includes current queue depth and recent stage
        execution time. Queue telemetry can increase but never reduce the
        historical estimate, and the healthy-route rule never applies once a
        lookup has reached the browser or Opus stage or a residential handoff.

        Previously verified knowledge is looked up by `property_id`, not by
        address spelling. A stale result that still contains future dates is
        returned immediately with its original `retrieved_at` while a
        low-priority refresh runs in the background.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LookupRequest" }
      responses:
        "201":
          description: |
            Lookup created. `status` may already be `done` (cache hit)
            or `queued`/`running`/`partial` — the body is the same Lookup
            resource `GET /lookups/{lookup_id}` serves.
          headers:
            Cache-Control:
              schema: { type: string, example: "private, no-store, max-age=0" }
            Location:
              schema: { type: string, example: /v1/lookups/f47ac10b-58cc-4372-a567-0e02b2c3d479 }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Lookup" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Browser origin not allowed to create lookups (`invalid_request`). Server-to-server callers are unaffected.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "413":
          description: |
            The JSON body exceeds the 8 KiB parser limit. Returned as
            `invalid_request`; no lookup is created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "415":
          description: Body was not `application/json` (`invalid_request`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Address unresolved — `ambiguous_address`, `address_not_found`,
            `property_identifier_required` (the first line names no house
            number or building name), or `property_dates_unavailable` (the
            council lists the property but has no dates for it), with
            candidates embedded when a useful list is held. Also
            `postcode_outside_coverage` / `council_not_supported` (without
            candidates).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProblemWithCandidates" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/ServerError"
        "409":
          description: The Idempotency-Key was previously used for a different lookup request (`idempotency_conflict`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

    options:
      security: []
      operationId: preflightLookup
      summary: CORS preflight for creating a lookup
      description: |
        Answers the browser's preflight for `POST /lookups`. The
        allow-origin headers are returned only for an approved WhenIsBins
        origin; the response is 204 either way, and the absent headers are
        what stop a disallowed origin. Server-to-server callers send no
        `Origin` and never need this.
      responses:
        "204":
          description: Preflight answered. No body.
          headers:
            Access-Control-Allow-Origin:
              description: Echoed back only for an approved origin.
              schema: { type: string }
            Access-Control-Allow-Methods:
              schema: { type: string, example: "POST, OPTIONS" }
            Access-Control-Allow-Headers:
              schema: { type: string, example: "Authorization, Content-Type, Idempotency-Key" }
            Access-Control-Max-Age:
              schema: { type: integer, example: 86400 }
            Vary:
              schema: { type: string, example: Origin }
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /lookups/{lookup_id}:
    get:
      operationId: getLookup
      summary: Read progress or collection dates from an existing lookup without resubmitting
      description: |
        Fetch one snapshot of a lookup. Browser clients should normally
        use the long-polling `/wait` endpoint below instead of polling.
        Supports `If-None-Match`.
        Lookup records are retained for 30 days.
      parameters:
        - name: lookup_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: Current lookup state; `result` present from `partial` onward (and on some `failed` states, absent).
          headers:
            Cache-Control:
              schema: { type: string, example: "private, no-store, max-age=0" }
            ETag: { $ref: "#/components/headers/ETag" }
            Retry-After:
              description: Suggested seconds before the next poll (only while status is not final).
              schema: { type: integer, example: 2 }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Lookup" }
        "304": { description: Not modified since the given ETag. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No such lookup (bad id, or expired past 30-day retention).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /lookups/{lookup_id}/wait:
    get:
      operationId: waitForLookup
      summary: Wait for a lookup to change
      description: |
        Holds the request until the lookup changes or for at most about
        25 seconds. Reconnect with the last `X-Lookup-Cursor` value as
        `after`; this avoids frequent browser polling while preserving
        the same Lookup response shape.
      parameters:
        - name: lookup_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: Opaque cursor returned by the preceding response.
      responses:
        "200":
          description: Current state, returned when it changes or the hold expires.
          headers:
            Cache-Control:
              schema: { type: string, example: "private, no-store, max-age=0" }
            X-Lookup-Cursor:
              description: Opaque cursor to send as `after` on the next request.
              schema: { type: string }
            Retry-After:
              description: Suggested reconnect delay in seconds while non-final.
              schema: { type: integer, example: 1 }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Lookup" }
        "404":
          description: No such lookup (bad id, or expired past 30-day retention).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /schedules/{property_token}:
    get:
      operationId: getSchedule
      summary: Retrieve an existing address-free schedule using its saved property token
      description: |
        The stable, cacheable answer for a property — what reminder
        engines re-check with `If-None-Match` (a 304 costs us nothing;
        the ETag changes only when schedule content changes, e.g. a
        bank-holiday reschedule). `property_token` is the service-issued
        `property_id` without its internal `p:` namespace marker; logs treat
        both forms as personal data. Old `p:` path values permanently redirect
        to the prefix-free form.

        Stale-while-revalidate: a stale stored schedule is served
        as-is (its `retrieved_at` is the honest staleness signal)
        while a refresh happens in the background — staleness is
        NEVER a 404. 404 `no_schedule` means only "we have never
        answered for this property" — create a lookup.

        This representation is intentionally address-free. A `property_id`
        is not authentication, so the exact council-rendered address is
        returned only inside the private, no-store Lookup resource.
      parameters:
        - name: property_token
          in: path
          required: true
          schema: { type: string, example: "4c5ee6c2f2c7c959" }
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: The current schedule.
          headers:
            Cache-Control:
              schema: { type: string, example: "private, max-age=300" }
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Schedule" }
        "301":
          description: A legacy path containing the internal `p:` marker; follow `Location` to the canonical prefix-free URL.
          headers:
            Location:
              schema: { type: string, example: "/v1/schedules/4c5ee6c2f2c7c959" }
        "304": { description: Not modified since the given ETag. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No current schedule held for this property (`no_schedule`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /schedules/uprn/{uprn}:
    get:
      operationId: getScheduleByUprn
      summary: Current schedule for a UPRN (public feeds only)
      description: |
        The public form of the schedule resource (UPRN spine, 6 Sep 2026):
        the same address-free Schedule keyed by the property's Unique
        Property Reference Number instead of our opaque id. It exists only
        while the service runs with `PUBLIC_FEEDS=1`; otherwise every UPRN
        is 404 `no_schedule`.

        Two rules distinguish it from `/schedules/{property_token}`. It reads
        ONLY what the store already holds for a UPRN this service has
        verified against OS Open UPRN — a guessable URL never creates a cold
        lookup. A uniquely mapped stale schedule is served as-is with its
        honest `retrieved_at` while the ordinary bounded background refresh
        is admitted; an unknown, unverified or ambiguous identifier creates
        no work. Exactly one verified property may answer for a UPRN; two
        rows, or none, is 404.
        The UPRN is canonical digits (no zero padding); anything else is 404.
      parameters:
        - name: uprn
          in: path
          required: true
          schema: { type: string, pattern: "^[1-9][0-9]{0,11}$", example: "100023336956" }
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: The current schedule; `uprn` echoes the request.
          headers:
            Cache-Control:
              schema: { type: string, example: "private, max-age=300" }
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Schedule" }
        "304": { description: Not modified since the given ETag. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No verified schedule held for this UPRN, or public feeds are off (`no_schedule`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /schedules/council/{gss}/{id}:
    get:
      operationId: getScheduleByCouncilId
      summary: Current schedule for a council's own property id (public feeds only)
      description: |
        The council-id form of the public schedule resource (7 Sep 2026):
        the same address-free Schedule keyed by the council's own identifier
        for the property — the value its address list uses — under the
        council's GSS code. It is the public form for councils whose
        identifiers are not UPRNs. Same rules as `/schedules/uprn/{uprn}`:
        exists only with `PUBLIC_FEEDS=1`, resolves only what the store already
        holds, and requires exactly one property to answer. A uniquely mapped
        stale schedule may enter the bounded background-refresh path; an
        unknown, malformed or ambiguous identifier creates no work.
        `id` is the canonical form: whitespace removed, a ReCollect
        `GUID:service:tenant` reduced to its lower-case GUID, RFC 3986
        unreserved characters only; anything else is 404.
      parameters:
        - name: gss
          in: path
          required: true
          schema: { type: string, pattern: "^[ENSW][0-9]{8}$", example: "E09000005" }
        - name: id
          in: path
          required: true
          schema: { type: string, example: "2083293" }
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: The current schedule; `council_id` echoes the request.
          headers:
            Cache-Control:
              schema: { type: string, example: "private, max-age=300" }
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Schedule" }
        "304": { description: Not modified since the given ETag. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No schedule held for this council id, or public feeds are off (`no_schedule`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /councils:
    get:
      operationId: listCouncils
      summary: Coverage — every UK collecting council and its status
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: All councils.
          headers:
            Cache-Control:
              schema: { type: string, example: "private, max-age=3600" }
          content:
            application/json:
              schema:
                type: object
                required: [councils]
                properties:
                  councils:
                    type: array
                    items: { $ref: "#/components/schemas/Council" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /waste-types:
    get:
      operationId: listWasteTypes
      summary: The fixed waste_type vocabulary
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: All waste types with display names.
          headers:
            Cache-Control:
              schema: { type: string, example: "private, max-age=86400" }
          content:
            application/json:
              schema:
                type: object
                required: [waste_types]
                properties:
                  waste_types:
                    type: array
                    items: { $ref: "#/components/schemas/WasteType" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Free opaque token issued by the service operator. Request one by
        emailing hello@whenisbins.com. Send it only in the Authorization
        header, store it like a password, and do not embed it in public browser
        JavaScript. Tokens are individually revocable and use their own higher
        allowance, while outer network and global capacity limits still apply.

  parameters:
    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      schema: { type: string, example: '"a1b2c3"' }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Random token for safely retrying one lookup creation request. It must
        contain 1 to 128 visible ASCII characters. Reusing a key with a
        different canonical property request returns 409; only HMAC digests
        are retained by shared admission.
      schema:
        type: string
        minLength: 1
        maxLength: 128
        pattern: '^[!-~]+$'

  headers:
    Allow:
      description: The methods this resource accepts.
      schema: { type: string, example: GET }
    ETag:
      description: Changes only when the response content changes.
      schema: { type: string, example: '"a1b2c3"' }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, example: 60 }

  responses:
    NotModified:
      description: |
        The content has not changed since the `If-None-Match` ETag. No body.
    MethodNotAllowed:
      description: |
        The path exists but not with this method. Every resource below
        answers exactly one method; the `Allow` header names it.
      headers:
        Allow: { $ref: "#/components/headers/Allow" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
    ServerError:
      description: |
        An unexpected fault on our side (`server_error`). Nothing is
        implied about whether the work started; retry with the same
        `Idempotency-Key` where one applies.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
    ServiceUnavailable:
      description: |
        Required service configuration, a dependency, or shared lookup
        admission is temporarily unavailable (`service_unavailable`).
      headers:
        Retry-After:
          description: Present when the service can suggest a retry delay.
          schema: { type: integer, example: 60 }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
    BadRequest:
      description: Malformed input (e.g. `invalid_postcode`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: Supplied bearer token is malformed, unknown or revoked, or a token is missing during a token-only hold (`unauthorized`).
      headers:
        WWW-Authenticate:
          schema: { type: string, example: 'Bearer realm="WhenIsBins API"' }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: Request, provider or new-work allowance exceeded; optional limit_scope and token_request_url explain the limit.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Problem:
      type: object
      description: Honest failure, first-class (dictionary 2d). `detail` is citizen-actionable and never leaks internals.
      required: [problem, detail]
      properties:
        limit_scope:
          type: string
          enum: [network, requests, new_work, address_discovery]
        token_request_url:
          type: string
          format: uri
          example: 'mailto:hello@whenisbins.com?subject=Free%20API%20token'
        problem:
          type: string
          description: Machine-readable reason code.
          enum:
            - invalid_postcode
            - postcode_outside_coverage
            - council_not_supported
            - ambiguous_address
            - address_not_found
            - property_identifier_required   # free-text property names no house number or house/building name
            - council_site_unavailable
            - council_site_maintenance  # the council's own checker is showing a maintenance holding page; transient
            - property_dates_unavailable  # the council LISTS this property and publishes no collection dates for it; re-entering the address cannot help
            - lookup_not_found
            - no_schedule
            - unauthorized
            - rate_limited
            - idempotency_conflict
            - invalid_request      # malformed body, unknown field, wrong content type, disallowed origin
            - method_not_allowed   # 405; the Allow header names the verb
            - not_found            # unknown /v1 path
            - server_error         # unexpected 500; safe to retry
            - service_unavailable  # 503; not configured / dependency down
        detail:
          type: string
          example: We found several possible matches for "Flat 2" at that postcode — please pick your address from the list.
        council:
          allOf:
            - $ref: "#/components/schemas/CouncilRef"
          description: |
            Present on GET /addresses and POST /lookups refusals once the
            postcode has resolved to a council, so a client can send the
            citizen to the council's own checker (`lookup_url`) when we
            cannot answer.
        council_note:
          type: string
          description: |
            Optional plain-text note carried by some `council_not_supported`
            refusals — a word about what the citizen will find on the
            council's own page. Render it after the council link, never
            between the citizen and that link.

        postcode_representative:
          type: string
          description: |
            Whether a labelled same-postcode neighbour's schedule may answer
            when the exact property is not listed. `opt_in` = only after the
            citizen explicitly consents, by sending
            `allow_postcode_representative`; `automatic` = this council is
            measured homogeneous, so the labelled answer is served without
            asking; `unavailable` = measured high-harm, and every tier
            refuses the neighbour answer. Present only at property
            granularity, where the neighbour concept applies.
          enum: [automatic, opt_in, unavailable]

    ProblemWithCandidates:
      allOf:
        - $ref: "#/components/schemas/Problem"
        - type: object
          properties:
            candidates:
              type: array
              description: Present for ambiguous_address / address_not_found / property_identifier_required (when a list is held) so the citizen can pick without a second call.
              items: { $ref: "#/components/schemas/Candidate" }

    Candidate:
      type: object
      required: [id, label]
      properties:
        id:
          type: string
          description: Opaque service token — echo back as `property_id`; never parse it or assume it is a council UPRN.
          example: "p:4c5ee6c2f2c7c959"
        label:
          type: string
          description: The property exactly as the council's checker words it.
          example: "Flat 15, Twickenham Court, Arbury Road, Cambridge, CB4 2HX"

    InputOption:
      type: object
      required: [value, label]
      properties:
        value:
          type: string
          description: |
            The council's own text, submitted back verbatim in the request
            field named by `input_options.field`. Not an opaque id: this is
            the key the council's own checker uses.
          example: "A70--Glenbrook Rd To B7031"
        label:
          type: string
          description: The unit as the council words it - the row's title.
          example: "A70--Glenbrook Rd To B7031"
        detail:
          type: string
          description: |
            The council's own supporting text for this row, when it has one:
            an area's list of the roads and villages inside it, or a record's
            number range. Present only for shapes where the label alone is
            not recognisable.
          example: "Houton, Aglath Road, Breck Road and 5 more"
        hint:
          type: string
          description: |
            OUR plain-language explanation of the row, emitted only where the
            run can evidence it (a classified-road segment proven to be this
            postcode's road). Never a guess and never council text.
          example: "The stretch of Lanark Road West at your postcode"

    InputOptions:
      type: object
      description: |
        The council's OWN collection units for the field it asks for, when
        that council resolves bin days by road or area rather than per
        property. It exists so the citizen picks the council's wording
        instead of guessing it; a picked row is still matched by the same
        strict address rules as typed text, and ambiguity is still a refusal.
        Absent whenever no picker can be offered - which includes councils
        answered from the postcode alone, and any moment the list cannot be
        read. Absent means the ordinary free-text journey.
      required: [field, shape, options]
      properties:
        field:
          type: string
          description: Which POST /lookups field the chosen `value` fills.
          enum: [street, locality]
        shape:
          type: string
          description: |
            How the list behaves. `short_list` = a council search returns a
            handful of records. `type_to_narrow` = the whole list exists at
            once and must be filtered before it is rendered.
            `area_contents` = the row is an area, made recognisable by what
            the council lists inside it.
          enum: [short_list, type_to_narrow, area_contents]
        extra_fields:
          type: array
          description: |
            Other fields this council's input contract still needs beside the
            chosen row (for example a house number where the council keys a
            street but reports per building).
          items:
            type: string
            enum: [property, locality]
        query:
          type: string
          description: Echo of the `q` that produced these rows.
        total:
          type: integer
          description: How many of the council's records matched `q`, before the row cap.
        row_limit:
          type: integer
          description: The most rows this endpoint will ever return.
          example: 12
        needs_more_query:
          type: boolean
          description: |
            `true` when more records matched than may be rendered. `options`
            is then empty on purpose: ask for more of the name rather than
            showing a list nobody can read.
        not_listed_value:
          type: string
          description: |
            Sentinel `value` for the picker's final row ("none of these").
            Submitting it means the citizen wants ordinary address entry; it
            is never sent to a council.
          example: "__not_listed__"
        options:
          type: array
          items: { $ref: "#/components/schemas/InputOption" }

    CandidateList:
      type: object
      required: [postcode, council, required_input, candidates_source, candidates]
      properties:
        postcode:
          type: string
          description: Normalised form.
          example: CB4 2HX
        council: { $ref: "#/components/schemas/CouncilWithWait" }
        required_input:
          type: string
          description: |
            The next field this council's checker needs after the postcode.
            `none` means exactly that: this council's own checker resolves the
            collection from the postcode alone - it names the settlement or
            round itself - so nothing further should be asked, and POST
            /lookups accepts the postcode by itself. A `property` may still be
            sent and is still matched strictly; it is simply not required.
            Read this value for each lookup rather than keeping a council's
            former input contract: South Lanarkshire now requires a property
            (or a listed property_id), following its property-checker redesign.
          enum: [none, property_id, property, street, road_and_locality, street_and_property, settlement, settlement_or_road, normal_weekday, normal_weekday_and_locality, property_type]
        postcode_representative:
          type: string
          description: |
            Whether a labelled same-postcode neighbour's schedule may answer
            when the exact property is not listed. `opt_in` = only after the
            citizen explicitly consents, by sending
            `allow_postcode_representative`; `automatic` = this council is
            measured homogeneous, so the labelled answer is served without
            asking; `unavailable` = measured high-harm, and every tier
            refuses the neighbour answer. Present only at property
            granularity, where the neighbour concept applies.
          enum: [automatic, opt_in, unavailable]
        candidates_source:
          type: string
          description: |
            `council` = the list came from the council's own checker.
            `unavailable` = no list is currently held; POST free-text
            `property` instead. Commercial address data is never republished
            through this field.
          enum: [council, unavailable]
        candidates:
          type: array
          items: { $ref: "#/components/schemas/Candidate" }
        input_options: { $ref: "#/components/schemas/InputOptions" }

    CouncilRef:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
          description: GSS code — the canonical, rename-proof council identity.
          example: E07000008
        name:
          type: string
          example: Cambridge City Council
        lookup_url:
          type: string
          format: uri
          description: |
            The council's own bin-day checker page, when known (https only).
            The honest fallback for the citizen on every wait and failure —
            the same page a served Schedule reports as `source_url`.
          example: https://www.cambridge.gov.uk/check-when-your-bin-will-be-emptied

    CouncilWithWait:
      allOf:
        - $ref: "#/components/schemas/CouncilRef"
        - type: object
          required: [expected_wait_seconds, success_rate]
          properties:
            expected_wait_seconds:
              type: integer
              minimum: 0
              nullable: true
              description: |
                Route-aware recent cold-visit p90, rounded up to seconds.
                When at least half of observations succeed through a fast
                deterministic route, this is that route's p90; after a fast
                miss it is promoted to at least 120 for the browser handoff.
                Poor success below 0.7 also selects the handoff. Null until
                five timed cold observations exist in the rolling 84-day window.
                While the route maintainer's control plane holds the council's
                active deterministic route healthy, the deterministic-stage
                estimate is instead the p90 of recent cold successes on the
                fast routes alone (null below five such successes), whatever
                the blended history or success rate says.
                A lookup parked for the residential worker after a council
                bot-blocked the web host is estimated from that worker's own
                deterministic stage timing (pickup plus execution, p90) from
                its first recent sample; with none it shows the handoff.
            success_rate:
              type: number
              format: double
              minimum: 0
              maximum: 1
              nullable: true
              description: |
                Share of recent cold observations whose outcome is `success`.
                Null until five cold observations exist in the rolling
                84-day window. Cache hits are never included.

    Council:
      allOf:
        - $ref: "#/components/schemas/CouncilRef"
        - type: object
          required: [coverage]
          properties:
            coverage:
              type: string
              enum: [live, degraded, unsupported]

    WasteType:
      type: object
      required: [id, display_name]
      properties:
        id:
          type: string
          enum: [refuse, recycling, garden, food, glass, textiles, electricals, paper_card, plastics_cans, other]
        display_name:
          type: string
          example: General waste

    LookupRequest:
      type: object
      description: |
        Send only the field or fields `GET /addresses` asked for. Any field
        not listed here is rejected with 400 `invalid_request` naming it —
        the body is not silently ignored.
      required: [postcode]
      properties:
        postcode:
          type: string
          example: CB4 2HX
        property:
          type: string
          maxLength: 300
          description: |
            The first line of the address (optional — free text, including the
            manual-entry flow): a house number or name, optionally preceded by
            its flat/unit and followed by its street, comma-separated —
            `15`, `Rose Cottage`, `Flat 2, 60, Kings Road`. It must carry a
            house number or a house/building name; a flat, floor or unit
            descriptor alone is refused with `property_identifier_required`.
            Ignored when `property_id` is given.
          example: "15"
        property_id:
          type: string
          maxLength: 128
          description: A candidate id previously returned; selects the service's stored candidate mapping and is not itself council-address evidence.
          example: "p:4c5ee6c2f2c7c959"
        street:
          type: string
          maxLength: 200
          description: Street/road name, accepted only when GET /addresses requests a street-based input.
          example: "Church Road"
        locality:
          type: string
          maxLength: 200
          description: Council-recognised town, locality, settlement, island, or legacy collection area when requested by GET /addresses.
          example: "Coleraine"
        normal_weekday:
          type: string
          description: Citizen-confirmed normal collection weekday. It is used only inside dates/weeks the council explicitly publishes and is never inferred from a neighbour.
          enum: [Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday]
        property_type:
          type: string
          description: City of London residential service model when requested; rule-level results are not represented as a dated property calendar.
          enum: [private_flat_without_bin_store, private_block_with_bin_store, housing_estate, street_bag_collection]
        allow_postcode_representative:
          type: boolean
          default: false
          description: |
            Q10 consent flag. Send ONLY after the citizen explicitly
            opts in to labelled neighbour-based dates; honoured only
            when the exact property is not listed. Never the default.

    Lookup:
      type: object
      required: [id, status, created_at, council, expected_wait_seconds, success_rate]
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          description: |
            `partial` = a usable result is attached while fuller dates
            (ICS/PDF completion) are still being fetched — check each
            collection's `dates_complete`.
          enum: [queued, running, partial, done, failed]
        created_at: { type: string, format: date-time }
        council:
          nullable: true
          description: Collecting council. Null only for a retained legacy lookup that predates council identity storage.
          allOf:
            - $ref: "#/components/schemas/CouncilRef"
        expected_wait_seconds:
          type: integer
          minimum: 0
          nullable: true
          description: |
            Route-aware recent cold-visit p90, rounded up to seconds. Fast
            deterministic routes supply the initial estimate when they serve
            at least half of recent cold visits; a deterministic miss promotes
            the lookup to at least 120 for the browser handoff. Null until five
            timed cold observations exist in the rolling 84-day window. While
            the route maintainer's control plane holds the council's active
            deterministic route healthy, the deterministic-stage estimate is
            instead the p90 of recent cold successes on the fast routes alone
            (null below five such successes), whatever the blended history or
            success rate says. A
            lookup parked for the residential worker after a council
            bot-blocked the web host is estimated from that worker's own
            deterministic stage timing (pickup plus execution, p90) from its
            first recent sample; with none it shows the handoff.
        success_rate:
          type: number
          format: double
          minimum: 0
          maximum: 1
          nullable: true
          description: |
            Share of cold observations with outcome `success` in the same
            rolling window. Null until five eligible cold observations exist.
            Cache hits are never included.
        completed_at:
          type: string
          format: date-time
          nullable: true
        result:
          $ref: "#/components/schemas/LookupSchedule"
        problem:
          type: string
          description: Present when status is `failed` (same codes as Problem).
        detail:
          type: string
          description: Citizen-actionable explanation when `failed`.
        candidates:
          type: array
          description: |
            Present only when a failed `address_not_found` lookup discovered
            the council's candidate list while it was running. Clients can
            resubmit the original postcode with one selected `property_id`.
            The Lookup resource does not echo the postcode or typed address.
          items: { $ref: "#/components/schemas/Candidate" }
        progress:
          type: object
          description: Safe, optional progress information for a non-final lookup.
          properties:
            stage:
              type: string
              enum: [deterministic, browser, opus]
            message:
              type: string
            steps:
              type: array
              description: |
                The rungs this lookup has reached, oldest first, ending with
                the one it is on. The ladder is a fallback chain, not a
                pipeline — every rung does the same job — so a rung the lookup
                has moved past was attempted and did not produce an answer
                (`missed`), never "completed". Rungs not yet reached are not
                listed: a lookup served by the first rung reports exactly one
                step. A refresh or legacy lookup is off the ladder and reports
                an empty list.
              items:
                type: object
                required: [stage, state]
                properties:
                  stage:
                    type: string
                    enum: [deterministic, browser, opus]
                  state:
                    type: string
                    description: |
                      `missed` = attempted, produced no answer, escalated;
                      `active` = running now; `waiting` = queued for the
                      fallback worker and not yet started.
                    enum: [missed, active, waiting]
            queue_ahead:
              type: integer
              minimum: 0
              description: |
                Citizen lookups the single fallback worker must finish before
                this one starts — the same work-ahead set that informs
                `expected_wait_seconds`, counted rather than timed. Present
                only while a rung is `waiting` on that worker; absent for the
                web pool's own deterministic path, and absent when no queue
                snapshot could be read. A count of queued rows is a fact
                rather than a projection, so it needs no sample threshold.

    LookupSchedule:
      description: |
        The address-bearing answer attached only to a private, no-store
        Lookup. `matched_address` lets the citizen verify that the council
        answered for the property they selected; it is deliberately absent
        from the stable Schedule resource, calendar feeds and routine
        reminders.
      allOf:
        - $ref: "#/components/schemas/Schedule"
        - type: object
          required: [matched_address]
          properties:
            matched_address:
              type: string
              description: The property as the council's own system lists it, verbatim.
              example: "FLAT 15, TWICKENHAM COURT, ARBURY ROAD, CAMBRIDGE, CB4 2HX"

    Schedule:
      type: object
      description: The stable address-free answer (dictionary section 2). Says how the property was matched and how much to trust the dates without publishing the exact address.
      required: [property_id, address_match, council, collections, date_confidence, date_completeness, evidence_granularity, retrieved_at]
      properties:
        property_id:
          type: string
          description: Echo of the requested property id (mockup amendment 5) — clients should assert it matches what they asked for.
          example: "p:4c5ee6c2f2c7c959"
        address_match:
          type: string
          description: |
            `exact` includes a citizen's own pick. `street`/`postcode`
            = council publishes at that granularity (notes explain).
            `postcode_representative` = a labelled same-postcode neighbour
            answer. It may follow explicit consent, measured evidence that
            permits automatic serving, or the narrower `provisional` interim
            gate. It is never presented as an exact-property result.
          enum: [exact, street, postcode, postcode_representative]
        council: { $ref: "#/components/schemas/CouncilRef" }
        collections:
          type: array
          items: { $ref: "#/components/schemas/Collection" }
        date_confidence:
          type: string
          description: |
            `published_calendar` = real dates the council publishes.
            `council_projection` = dates published by the council's own
            projected calendar, or one next date calculated by the service's
            bounded normal-weekday rule (holiday changes may be missing).
            Council-published dates are retained through the 92-day horizon;
            only the service's own calculation is limited to one occurrence.
            `next_collection_only` = nothing beyond the next date(s)
            exists — do not imply more is coming.
          enum: [published_calendar, council_projection, next_collection_only]
        date_completeness:
          type: string
          description: |
            Separate from validity: `next_only` is concrete next date(s),
            `limited_horizon` is a valid incomplete slice, `full_horizon`
            was verified across the serving horizon, and `weekday_only`
            contains council-published weekday wording that could not safely
            be translated into dates. Weekday projections are
            `limited_horizon` and contain at most the next date for each
            collection type. A nearby UK bank holiday does not suppress that
            date; any explicit council exception the run saw takes precedence.
          enum: [next_only, limited_horizon, full_horizon, weekday_only]
        evidence_granularity:
          type: string
          description: The level at which the council publishes the schedule evidence; separate from how the requested address was matched.
          enum: [property, street, postcode, area, round]
        retrieved_at:
          type: string
          format: date-time
          nullable: true
          description: When we fetched this from the council (answers are cached). Null for historical cached lookups whose original retrieval time was not retained; never substitute the time the cache was read.
        source_revalidated_at:
          type: string
          format: date-time
          description: When a shared source artifact was most recently conditionally revalidated.
        artifact_version:
          type: string
          description: Opaque content-hash and parser-version identity for a deterministic shared calendar.
        provisional:
          type: boolean
          description: |
            Present and `true` only on an INTERIM answer: a same-postcode
            neighbour's schedule, served in under a second while the lookup for
            this exact property is still running. It is always accompanied by
            `address_match: postcode_representative`, `dates_complete: false`
            on every collection, and a `notes` caveat naming the neighbour.
            The lookup carrying it is `partial`, never `done`.

            An interim is never cached: it does not appear at
            `GET /v1/schedules/{property_token}`, in a calendar feed, in a
            reminder or in an email, and it is replaced in place by the exact
            answer as soon as that arrives. A client that treats it as final
            is misreading it — keep polling the lookup.

            It is fail-closed: the council must pass the measured zero-mismatch
            gate and the postcode's held comparable neighbours must also pass
            the serve-time verification gate. See
            docs/interim-then-upgrade-serving-2026-08-27.md for the internal
            evidence thresholds; clients must rely on `provisional`, not
            reproduce those policies.
        notes:
          type: string
          description: Plain-English caveats written for the citizen (assisted collections, communal bins, granularity, …).
        source_url:
          type: string
          format: uri
          description: |
            The council's own checker page ("check on your council's site"):
            the page the serving run actually read, else the council's known
            checker page from our council intel. https only.
        uprn:
          type: string
          description: |
            The property's Unique Property Reference Number, present only
            when the service has verified it against OS Open UPRN AND public
            feeds are on (`PUBLIC_FEEDS=1`). It keys the shareable public
            calendar `https://whenisbins.com/{uprn}.ics` and the
            `/schedules/uprn/{uprn}` form of this resource. Canonical digits,
            no zero padding.
          example: "100023336956"
        council_id:
          type: string
          description: |
            The council's own identifier for the property (the value its
            address list uses), canonical form, present only when the service
            captured it from an exact match AND public feeds are on. Keys
            `https://whenisbins.com/{council.id}/{council_id}.ics` and the
            `/schedules/council/{gss}/{id}` form of this resource — the
            public calendar for councils whose identifiers are not UPRNs.
          example: "2083293"

    Collection:
      type: object
      description: One waste service. Kerbside-sort councils genuinely run 6–8; never merged.
      required: [name, waste_type, dates, dates_complete, bin_colour, lid_colour, colour_source, container]
      properties:
        name:
          type: string
          description: The service exactly as the council words it — never rewritten.
          example: "Grey bin – household waste"
        waste_type:
          type: string
          enum: [refuse, recycling, garden, food, glass, textiles, electricals, paper_card, plastics_cans, other]
        dates:
          type: array
          description: >-
            Upcoming dates from today through 92 days ahead, sorted. Normally
            council-published; a council-published normal weekday may be
            translated into its single next occurrence by the bounded
            deterministic projection rule and is labelled with schedule-level
            `date_confidence: council_projection`. Clients render weekday +
            date ("Wednesday 12th August") — derived, not shipped.
          items: { type: string, format: date }
          example: ["2026-08-14", "2026-08-28"]
        schedule:
          type: string
          description: Council wording such as a weekday/time. Required when `date_completeness` is `weekday_only` and retained when it is the basis of a labelled weekday projection. Consumers must not expand it themselves.
          example: "Every Tuesday, 6:30am to 8:30am"
        dates_complete:
          type: boolean
          description: False while a fuller calendar is being fetched, or when this service has no remaining dates in a historical dated answer. For a done lookup with missing dates, request a fresh lookup; polling the old lookup does not refresh it.
        bin_colour:
          type: string
          description: Always present — council-stated, else the neutral default `light_grey` (we default, we never guess).
          example: grey
        lid_colour:
          type: string
          description: Defaults to the bin's colour when unstated.
          example: black
        colour_source:
          type: string
          enum: [council, default]
        container:
          type: string
          description: What to draw. Council-stated, else defaults to wheelie_bin; covered by colour_source.
          enum: [wheelie_bin, caddy, box, bag, sack, communal]
        subscription_required:
          type: boolean
          description: |
            Present and true when the council's own text says this service is
            paid or opt-in. Also true for garden waste on a representative
            answer, because dates may transfer while the exact property's
            subscription entitlement remains unproven.
