openapi: 3.0.3
info:
  title: outages.uk API
  version: "1"
  description: >
    Live UK power cut data, aggregated across every electricity distribution network
    operator (DNO). Errors follow RFC 9457 (problem+json). Standard keys are
    rate-limited to 10 requests/minute and see a rolling 12-hour data window (plus 60
    minutes of grace after an incident is restored) -- GET /v1/history lifts the
    window for premium keys, granted by hand, no self-serve upgrade yet. This file is
    hand-maintained against the actual route handlers in src/api/routes/ -- keep it in
    sync when they change; see CLAUDE.md's API conventions.
  contact:
    url: https://outages.uk/developers
servers:
  - url: https://api.outages.uk
security:
  - bearerAuth: []
paths:
  /v1/keys:
    post:
      summary: Register an API key
      security: []
      description: >
        No key required -- you don't have one yet. One active key per email:
        registering again revokes any existing key for that address and issues a new
        one. The returned key is shown once and never again; only its hash is stored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        '201':
          description: Key created
          content:
            application/json:
              schema:
                type: object
                properties:
                  apiKey: { type: string, example: "ouk_live_9f2c...redacted" }
                  email: { type: string, format: email }
                  createdAt: { type: string, format: date-time }
                  notice: { type: string }
        '400':
          $ref: '#/components/responses/Problem'
        '429':
          $ref: '#/components/responses/Problem'

  /v1/incidents:
    get:
      summary: List current incidents
      description: >
        Current incidents across every operator -- active, scheduled, unknown-status,
        and anything restored within the last hour -- cursor-paginated.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          description: From a previous response's nextCursor.
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  generatedAt: { type: string, format: date-time }
                  operators:
                    type: array
                    items: { $ref: '#/components/schemas/OperatorFreshness' }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Incident' }
                  nextCursor: { type: string, nullable: true }
        '401':
          $ref: '#/components/responses/Problem'
        '429':
          $ref: '#/components/responses/Problem'

  /v1/postcode-check:
    get:
      summary: Check whether a postcode is currently affected
      parameters:
        - name: postcode
          in: query
          required: true
          description: A full unit postcode (N1 9GU) or a bare sector (N1 9).
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  generatedAt: { type: string, format: date-time }
                  operators:
                    type: array
                    items: { $ref: '#/components/schemas/OperatorFreshness' }
                  postcode: { type: string }
                  matched: { type: boolean }
                  incidents:
                    type: array
                    items: { $ref: '#/components/schemas/Incident' }
        '400':
          $ref: '#/components/responses/Problem'
        '401':
          $ref: '#/components/responses/Problem'
        '429':
          $ref: '#/components/responses/Problem'

  /v1/status:
    get:
      summary: Per-operator freshness
      description: Uncached -- the source of truth the other endpoints' freshness envelopes refer to.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  generatedAt: { type: string, format: date-time }
                  operators:
                    type: array
                    items: { $ref: '#/components/schemas/OperatorFreshness' }
        '401':
          $ref: '#/components/responses/Problem'
        '429':
          $ref: '#/components/responses/Problem'

  /v1/history:
    get:
      summary: Full incident history (premium)
      description: >
        Identical shape and parameters to GET /v1/incidents, but without the 12-hour
        free-tier window. Requires a premium key -- there is no self-serve upgrade
        path; a standard key gets 403 Forbidden.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  generatedAt: { type: string, format: date-time }
                  operators:
                    type: array
                    items: { $ref: '#/components/schemas/OperatorFreshness' }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Incident' }
                  nextCursor: { type: string, nullable: true }
        '401':
          $ref: '#/components/responses/Problem'
        '403':
          $ref: '#/components/responses/Problem'
        '429':
          $ref: '#/components/responses/Problem'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: An API key from POST /v1/keys, sent as "Authorization Bearer <key>".
  responses:
    Problem:
      description: RFC 9457 problem+json
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
  schemas:
    Problem:
      type: object
      properties:
        type: { type: string }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
    OperatorFreshness:
      type: object
      properties:
        operator: { type: string, example: ukpn }
        name: { type: string }
        stale: { type: boolean }
        lastSuccessAt: { type: string, format: date-time, nullable: true }
        lastAttemptAt: { type: string, format: date-time, nullable: true }
    RestorationClaim:
      oneOf:
        - type: object
          properties:
            kind: { type: string, enum: [none, to-be-confirmed] }
        - type: object
          properties:
            kind: { type: string, enum: [point] }
            at: { type: string, format: date-time }
        - type: object
          properties:
            kind: { type: string, enum: [window] }
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }
    Incident:
      type: object
      properties:
        id: { type: string, example: "ukpn:INCD-105479-V" }
        operator:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            attribution:
              type: string
              nullable: true
              description: >
                CC BY 4.0 attribution string. null means this operator's licence is not
                yet cleared for redistribution -- do not show these incidents to your
                own users.
        licenceArea: { type: string, nullable: true }
        status: { type: string, enum: [active, restored, scheduled, unknown] }
        cause: { type: string, enum: [planned, unplanned, unknown] }
        location:
          type: object
          properties:
            postcodeSectors: { type: array, items: { type: string } }
            postcodeUnits: { type: array, items: { type: string } }
            point:
              type: object
              nullable: true
              properties:
                lon: { type: number }
                lat: { type: number }
            pointMeaning:
              type: string
              enum: [affected-area-centroid, network-asset, derived-from-postcodes, none]
            areaName: { type: string, nullable: true }
        customersAffected: { type: integer, nullable: true }
        callsReported: { type: integer, nullable: true }
        startedAt: { type: string, format: date-time, nullable: true }
        reportedAt: { type: string, format: date-time, nullable: true }
        restoredAt: { type: string, format: date-time, nullable: true }
        scheduledFor: { type: string, format: date-time, nullable: true }
        estimatedRestoration:
          type: object
          properties:
            claim: { $ref: '#/components/schemas/RestorationClaim' }
            displayText:
              type: string
              nullable: true
              description: The operator's own wording, where they publish one.
        description: { type: string, nullable: true }
        message: { type: string, nullable: true }
        fetchedAt: { type: string, format: date-time }
        sourceUpdatedAt: { type: string, format: date-time, nullable: true }
