openapi: 3.0.3
info:
  title: PeakShift Partner API
  version: 1.0.0
  description: |
    The PeakShift Partner API gives your application consented access to residential
    **electricity interval data** from Ontario utilities, delivered through
    Green Button Connect My Data (CMD) and normalized to a single JSON model.

    ## Coverage
    - **18 Ontario electricity distributors live**, plus **Milton Hydro in testing**
      (`GET /v1/utilities` is the authoritative list).
    - Electricity only (no natural gas). Ontario only.
    - Hourly interval data (`interval: "1h"`), the resolution Ontario smart meters record for TOU billing.

    ## Environments
    The environment is a property of your API key — the base URL is the same.

    | | Sandbox key (`pk_sandbox_…`) | Live key (`pk_live_…`) |
    |---|---|---|
    | Utilities list & coverage lookup | Yes | Yes |
    | Consent flow | Simulated authorization page hosted by PeakShift | Enabled per partner on request (returns `503 live_consent_unavailable` until enabled) |
    | Account data | Deterministic **synthetic** data (`"simulated": true`) | Real Green Button data for accounts your key connected |

    Sandbox and live accounts are completely separate. Every account belongs to the API key
    that connected it; requesting any other account returns `404 account_not_found`.

    To request sandbox access or live enablement, email **contact@peakshift.ca**.

    ## Quickstart (sandbox)
    ```bash
    export PEAKSHIFT_KEY=pk_sandbox_...
    BASE=https://www.peakshift.ca/api

    # 1. List supported utilities
    curl -s $BASE/v1/utilities -H "Authorization: Bearer $PEAKSHIFT_KEY"

    # 2. Find the utility serving an address
    curl -s "$BASE/v1/utilities/lookup?postal_code=M5V2T6" -H "Authorization: Bearer $PEAKSHIFT_KEY"

    # 3. Start consent for a customer. account_id is a UUID your application generates.
    curl -s -X POST $BASE/v1/auth/consent/initiate \
      -H "Authorization: Bearer $PEAKSHIFT_KEY" -H "Content-Type: application/json" \
      -d '{"account_id":"3f6c1b9e-2d4a-4c8e-9b1f-7a2e5d8c0f41","utility":"toronto_hydro",
           "redirect_uri":"https://yourapp.example/peakshift/callback","state":"xyz123"}'

    # 4. Open authorization_url from the response in a browser and click "Approve (simulated)".
    #    You are redirected to:
    #    https://yourapp.example/peakshift/callback?consent_session_id=...&account_id=3f6c...&state=xyz123&status=authorized&utility=toronto_hydro

    # 5. Fetch account metadata
    curl -s $BASE/v1/accounts/3f6c1b9e-2d4a-4c8e-9b1f-7a2e5d8c0f41/metadata -H "Authorization: Bearer $PEAKSHIFT_KEY"

    # 6. Fetch the energy profile (12 months hourly; or daily / monthly, JSON or CSV)
    curl -s "$BASE/v1/accounts/3f6c1b9e-2d4a-4c8e-9b1f-7a2e5d8c0f41/energy-profile?granularity=monthly" \
      -H "Authorization: Bearer $PEAKSHIFT_KEY"
    ```

    ## Consent redirect
    After the customer acts on the authorization page, the browser is redirected (HTTP 303)
    to your `redirect_uri` with these query parameters:

    | Parameter | When | Value |
    |---|---|---|
    | `consent_session_id` | always | Session id returned by initiate |
    | `account_id` | always | The `account_id` you supplied |
    | `state` | if you supplied `state` | Your value, unchanged — verify it |
    | `status` | approved | `authorized` |
    | `utility` | approved | Utility code |
    | `error` | not approved | `access_denied` (customer denied), `account_conflict` (account_id taken by another key), `server_error` |

    Authorization links expire after **30 minutes** and are **single use**.

    ## Sandbox data
    Sandbox profiles are synthetic but realistic, and deterministic: the same
    `account_id` + `utility` always returns the same household and readings. Data covers the
    last 12 full calendar months in `America/Toronto` (8,760 hourly readings; 8,784 in a
    leap year) and rolls forward at the start of each month. Each account is assigned a
    household archetype (gas-heated, electric-heated or heat pump; with or without an EV
    and central air conditioning) and a regional climate based on the utility (e.g. colder
    winters for Greater Sudbury, warmer summers for Windsor/Essex). Readings show daily and
    seasonal patterns, weekday/weekend differences and evening peaks. The archetype is
    returned in `sandbox_household` (sandbox only) so you can check your analysis.
    Service addresses are clearly fake (`123 Sandbox St`).

    ## Rate limits
    Each API key has a per-minute request limit (sandbox keys default to 60/min).
    Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`. When exceeded you get
    `429 rate_limit_exceeded` with `Retry-After: 60` (seconds) and `X-RateLimit-Reset`
    (Unix time). The window is a fixed 60 seconds starting at your first request in it.

    ## Errors
    Errors use one shape: `{"error": {"code": "...", "message": "..."}}`.

    | HTTP | code | Meaning |
    |---|---|---|
    | 400 | `invalid_request` | Body is not a JSON object |
    | 400 | `invalid_parameter` | A parameter is missing or invalid (message says which) |
    | 400 | `validation_error` | Webhook registration input is invalid |
    | 401 | `unauthorized` | Missing, invalid, inactive or expired API key |
    | 404 | `account_not_found` | Account does not exist or was not connected by your key |
    | 404 | `not_covered` | No covered utility for the location (or outside Ontario) |
    | 409 | `account_conflict` | `account_id` is already used by another API key — generate a new UUID |
    | 429 | `rate_limit_exceeded` | Per-key limit exceeded (includes `retry_after`) |
    | 500 | `server_error` | Unexpected error — safe to retry with backoff |
    | 503 | `live_consent_unavailable` | Live consent not yet enabled for your key |
  contact:
    name: PeakShift
    email: contact@peakshift.ca
    url: https://www.peakshift.ca
  license:
    name: Proprietary
    url: https://www.peakshift.ca/legal/terms

servers:
  - url: https://www.peakshift.ca/api
    description: Production (sandbox and live keys)

security:
  - BearerAuth: []

tags:
  - name: Coverage
    description: Supported utilities and address coverage lookup
  - name: Consent
    description: Start a customer authorization
  - name: Accounts
    description: Data for accounts your API key has connected
  - name: Webhooks (beta)
    description: Webhook registration. Beta — events are not emitted yet (including in sandbox).

paths:
  /v1/utilities:
    get:
      tags: [Coverage]
      operationId: listUtilities
      summary: List supported utilities
      description: All utilities in PeakShift coverage, with status `live` or `testing`. Works with sandbox and live keys.
      responses:
        '200':
          description: Utility list
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
          content:
            application/json:
              schema:
                type: object
                required: [data, live_count, testing_count]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Utility' }
                  live_count: { type: integer, example: 18 }
                  testing_count: { type: integer, example: 1 }
              example:
                data:
                  - code: toronto_hydro
                    name: Toronto Hydro
                    status: live
                    regions: [Toronto, North York, Scarborough, Etobicoke, East York, York]
                    approx_customers: 800000
                    data_types: [electricity_interval]
                    interval: 1h
                  - code: milton_hydro
                    name: Milton Hydro
                    status: testing
                    regions: [Milton, Campbellville, Halton Hills]
                    approx_customers: 35000
                    data_types: [electricity_interval]
                    interval: 1h
                live_count: 18
                testing_count: 1
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/utilities/lookup:
    get:
      tags: [Coverage]
      operationId: lookupUtility
      summary: Find the utility serving a location
      description: |
        Best-effort lookup by postal code (or FSA, the first 3 characters) or by city name.
        Utility territories do not follow postal boundaries exactly, so the response includes
        `match_type` and `confidence`:

        - `fsa` — exact FSA match (`high`; `medium` for rural FSAs whose 2nd character is `0`)
        - `fsa_prefix` — matched on the first two characters (`high` for Toronto `M` codes,
          `medium` otherwise, `low` for the Hydro One rural fallback)
        - `city` — matched on a municipality name (`medium`)

        Confirm the utility with the customer (e.g. from their bill) when confidence is not `high`.
        If both parameters are given, `postal_code` is used.
      parameters:
        - name: postal_code
          in: query
          schema: { type: string, example: M5V 2T6 }
          description: Canadian postal code (`A1A 1A1`, space optional) or FSA (`A1A`).
        - name: city
          in: query
          schema: { type: string, example: Waterloo }
          description: Municipality name (case-insensitive, optional trailing `, ON`).
      responses:
        '200':
          description: Serving utility found
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LookupResult' }
              example:
                query: { postal_code: M5V 2T6 }
                utility:
                  code: toronto_hydro
                  name: Toronto Hydro
                  status: live
                  regions: [Toronto, North York, Scarborough, Etobicoke, East York, York]
                  approx_customers: 800000
                  data_types: [electricity_interval]
                  interval: 1h
                match_type: fsa_prefix
                confidence: high
        '400':
          description: Missing or malformed parameter (`invalid_parameter`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: invalid_parameter, message: 'postal_code must be a valid Canadian postal code (A1A 1A1) or FSA (A1A)' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Location not covered (`not_covered`), including all locations outside Ontario
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: not_covered, message: PeakShift currently covers Ontario electricity utilities only } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/auth/consent/initiate:
    post:
      tags: [Consent]
      operationId: initiateConsent
      summary: Start a customer authorization
      description: |
        Creates a consent session and returns an `authorization_url` to open in the customer's browser.

        **Sandbox keys:** the URL is a PeakShift-hosted, clearly labelled *simulated utility
        authorization* page with Approve / Deny buttons. Approving creates a sandbox account
        that returns synthetic data. Any utility from `GET /v1/utilities` (live or testing) is accepted.

        **Live keys:** live consent is enabled per partner. Until it is enabled for your key this
        endpoint returns `503 live_consent_unavailable`.

        `account_id` is a UUID that your application generates and stores for the customer.
        It belongs to the first API key that uses it; reusing it with the same key starts a new
        consent (and may change the utility), while another key gets `409 account_conflict`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsentInitiateRequest' }
            example:
              account_id: 3f6c1b9e-2d4a-4c8e-9b1f-7a2e5d8c0f41
              utility: toronto_hydro
              redirect_uri: https://yourapp.example/peakshift/callback
              state: xyz123
      responses:
        '201':
          description: Consent session created
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConsentInitiateResponse' }
              example:
                consent_session_id: 9b2d7c4e-1f3a-4e6b-8c5d-2a7f9e1b3c60
                authorization_url: https://www.peakshift.ca/sandbox/consent/9b2d7c4e-1f3a-4e6b-8c5d-2a7f9e1b3c60
                expires_at: '2026-10-08T15:30:00.000+00:00'
                environment: sandbox
        '400':
          description: Invalid body (`invalid_request`) or parameter (`invalid_parameter`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: invalid_parameter, message: 'redirect_uri must use https (http is allowed only for localhost / 127.0.0.1)' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: account_id already used by another API key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: account_conflict, message: This account_id is already in use. Generate a new UUID. } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503':
          description: Live key — live consent not yet enabled for this partner
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: live_consent_unavailable, message: Live consent onboarding is enabled per partner. Contact contact@peakshift.ca. } }

  /v1/accounts/{accountId}/metadata:
    get:
      tags: [Accounts]
      operationId: getAccountMetadata
      summary: Get account metadata
      description: Connection status, utility, service points, data range and (sandbox) a simulated service address.
      parameters:
        - $ref: '#/components/parameters/AccountId'
      responses:
        '200':
          description: Account metadata
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountMetadata' }
              example:
                environment: sandbox
                simulated: true
                account_id: 3f6c1b9e-2d4a-4c8e-9b1f-7a2e5d8c0f41
                utility: toronto_hydro
                utility_name: Toronto Hydro
                service_points: [SANDBOX-3F6C1B9E]
                data_available: true
                date_range: { earliest: '2025-10-01', latest: '2026-09-30' }
                last_sync: '2026-10-01T04:00:00.000Z'
                quality_score: 100
                status: ready
                connected_at: '2026-10-08T15:02:11.000+00:00'
                service_address:
                  line1: 123 Sandbox St
                  city: Toronto
                  province: 'ON'
                  postal_code_fsa: M4C
                  country: CA
                rate_plan: TOU
                meter_type: smart
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/AccountNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/accounts/{accountId}/energy-profile:
    get:
      tags: [Accounts]
      operationId: getEnergyProfile
      summary: Get energy profile (interval usage + summary)
      description: |
        Returns interval electricity usage plus a summary of the account's load profile.

        - `usage` and `period` reflect the requested window (`start`/`end`) and `granularity`.
        - `summary` and `metadata` always describe the **full** available history (12 months),
          regardless of `start`, `end` or `granularity`.
        - Timestamps are ISO 8601 in `America/Toronto` local time with UTC offset
          (e.g. `2026-01-15T18:00:00-05:00`); each reading covers the interval starting at its timestamp.
        - `daily` / `monthly` buckets start at local midnight; their `kwh` is the sum of the hourly
          readings and `quality` is the lowest-quality flag among them.
        - With `format=csv` the response is `text/csv` with columns `timestamp,kwh,quality`
          (same window and granularity rules).
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - name: granularity
          in: query
          schema: { type: string, enum: [hourly, daily, monthly], default: hourly }
        - name: start
          in: query
          description: First local date to include (YYYY-MM-DD, inclusive). Clamped to the available range.
          schema: { type: string, format: date, example: '2026-01-01' }
        - name: end
          in: query
          description: Last local date to include (YYYY-MM-DD, inclusive). Clamped to the available range.
          schema: { type: string, format: date, example: '2026-01-31' }
        - name: format
          in: query
          schema: { type: string, enum: [json, csv], default: json }
        - name: utility
          in: query
          description: Optional consistency check. If supplied it must equal the account's connected utility, otherwise `400 invalid_parameter`.
          schema: { type: string, example: toronto_hydro }
      responses:
        '200':
          description: Energy profile
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EnergyProfile' }
              example:
                environment: sandbox
                simulated: true
                account_id: 3f6c1b9e-2d4a-4c8e-9b1f-7a2e5d8c0f41
                utility: toronto_hydro
                timezone: America/Toronto
                interval: 1mo
                period: { start: '2025-10-01', end: '2026-09-30', total_days: 365 }
                usage:
                  - { timestamp: '2025-10-01T00:00:00-04:00', kwh: 612.408, quality: actual }
                  - { timestamp: '2025-11-01T00:00:00-04:00', kwh: 688.917, quality: actual }
                summary:
                  annual_kwh: 8301.2
                  avg_daily_kwh: 22.74
                  peak_hour: 18
                  peak_month: 8
                  seasonal_profile: { summer_avg_kwh: 1.03, winter_avg_kwh: 1.02, shoulder_avg_kwh: 0.871 }
                  load_shape: standard_residential
                  data_quality_score: 100
                metadata:
                  service_points: [SANDBOX-3F6C1B9E]
                  last_updated: '2026-10-01T04:00:00.000Z'
                  data_quality_flags:
                    total_readings: 8760
                    actual_readings: 8760
                    estimated_readings: 0
                    interpolated_readings: 0
                    missing_readings: 0
                    gap_periods: []
                sandbox_household:
                  archetype: gas_heated
                  has_ev: false
                  has_central_ac: true
                  climate_region: gta
            text/csv:
              schema: { type: string }
              example: |
                timestamp,kwh,quality
                2025-10-01T00:00:00-04:00,0.642,actual
                2025-10-01T01:00:00-04:00,0.62,actual
        '400':
          description: Invalid query parameter (`invalid_parameter`), e.g. bad date, unknown granularity, range outside available data, or utility mismatch
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: invalid_parameter, message: 'Requested range does not overlap the available data (2025-10-01 to 2026-09-30)' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/AccountNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/webhooks/register:
    post:
      tags: [Webhooks (beta)]
      operationId: registerWebhook
      summary: Register a webhook (beta)
      description: |
        **Beta — registration only. Events are not emitted yet (including in sandbox).**
        Registers an HTTPS endpoint for future event delivery. The URL must be public HTTPS
        (private, loopback and link-local addresses are rejected). The signing `secret` is
        returned once. This endpoint is not rate limited and does not return rate-limit headers.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri, maxLength: 2048, example: 'https://yourapp.example/webhooks/peakshift' }
                events:
                  type: array
                  items: { type: string, enum: [data.fetch.completed, data.fetch.failed, data.sync.started] }
      responses:
        '201':
          description: Webhook registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  url: { type: string }
                  events: { type: array, items: { type: string } }
                  secret: { type: string, description: 'Signing secret (whsec_…), shown only once' }
                  is_active: { type: boolean }
                  created_at: { type: string, format: date-time }
        '400':
          description: Invalid input (`validation_error`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: validation_error, message: 'Missing required fields: url and events (array)' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Webhooks (beta)]
      operationId: listWebhooks
      summary: List your webhooks (beta)
      responses:
        '200':
          description: Webhooks registered by this API key (secrets are not returned)
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        url: { type: string }
                        events: { type: array, items: { type: string } }
                        is_active: { type: boolean }
                        last_triggered_at: { type: string, format: date-time, nullable: true }
                        created_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: 'API key in the Authorization header: `Authorization: Bearer pk_sandbox_…` or `Bearer pk_live_…`. Keep keys server-side.'

  parameters:
    AccountId:
      name: accountId
      in: path
      required: true
      description: The UUID you supplied as account_id when starting consent.
      schema: { type: string, format: uuid, example: 3f6c1b9e-2d4a-4c8e-9b1f-7a2e5d8c0f41 }

  headers:
    X-RateLimit-Limit:
      description: Requests allowed per minute for this API key
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current 60-second window
      schema: { type: integer }

  responses:
    Unauthorized:
      description: Missing, invalid, inactive or expired API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: unauthorized, message: Invalid API key } }
    AccountNotFound:
      description: Account does not exist or was not connected by this API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: account_not_found, message: Account not found } }
    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After: { description: Seconds to wait, schema: { type: integer, example: 60 } }
        X-RateLimit-Reset: { description: Unix time when the window resets, schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: rate_limit_exceeded, message: Rate limit exceeded. Retry after 60 seconds., retry_after: 60 } }
    ServerError:
      description: Unexpected server error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: server_error, message: Failed to fetch energy profile } }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [invalid_request, invalid_parameter, validation_error, unauthorized, account_not_found, not_covered, account_conflict, rate_limit_exceeded, server_error, live_consent_unavailable]
            message: { type: string }
            retry_after: { type: integer, description: Present on rate_limit_exceeded (seconds) }

    UtilityCode:
      type: string
      enum: [toronto_hydro, hydro_one, alectra, hydro_ottawa, elexicon, london_hydro, enwin, oakville_hydro, burlington_hydro, oshawa_power, newmarket_tay, festival_hydro, npei, elk_energy, enova, grandbridge, entegrus, greater_sudbury, milton_hydro]

    Utility:
      type: object
      required: [code, name, status, regions, data_types, interval]
      properties:
        code: { $ref: '#/components/schemas/UtilityCode' }
        name: { type: string, example: Toronto Hydro }
        status:
          type: string
          enum: [live, testing]
          description: '`testing` utilities are available in sandbox; live data is not yet in production.'
        regions: { type: array, items: { type: string }, description: Main municipalities / service areas (approximate) }
        approx_customers: { type: integer, nullable: true, description: Approximate distribution customers (public figures) }
        data_types: { type: array, items: { type: string, enum: [electricity_interval] } }
        interval: { type: string, example: 1h, description: Interval resolution returned by the API }

    LookupResult:
      type: object
      required: [query, utility, match_type, confidence]
      properties:
        query:
          type: object
          description: Normalized query (`postal_code` or `city`)
          properties:
            postal_code: { type: string, example: M5V 2T6 }
            city: { type: string, example: waterloo }
        utility: { $ref: '#/components/schemas/Utility' }
        match_type: { type: string, enum: [fsa, fsa_prefix, city] }
        confidence: { type: string, enum: [high, medium, low] }

    ConsentInitiateRequest:
      type: object
      required: [account_id, utility, redirect_uri]
      properties:
        account_id: { type: string, format: uuid, description: UUID generated by your application for this customer }
        utility: { $ref: '#/components/schemas/UtilityCode' }
        redirect_uri:
          type: string
          format: uri
          maxLength: 2048
          description: 'Absolute https URL (sandbox also accepts http://localhost and http://127.0.0.1). No fragment or credentials.'
        state:
          type: string
          maxLength: 200
          description: Optional opaque value returned unchanged on the redirect. Use it to prevent CSRF.

    ConsentInitiateResponse:
      type: object
      required: [consent_session_id, authorization_url, expires_at, environment]
      properties:
        consent_session_id: { type: string, format: uuid }
        authorization_url: { type: string, format: uri, description: Open in the customer's browser }
        expires_at: { type: string, format: date-time, description: 30 minutes after creation }
        environment: { type: string, enum: [sandbox] }

    AccountMetadata:
      type: object
      required: [environment, simulated, account_id, utility, service_points, data_available, date_range, status]
      properties:
        environment: { type: string, enum: [sandbox, live] }
        simulated: { type: boolean, description: true for sandbox data }
        account_id: { type: string, format: uuid }
        utility: { $ref: '#/components/schemas/UtilityCode' }
        utility_name: { type: string }
        service_points: { type: array, items: { type: string } }
        data_available: { type: boolean }
        date_range:
          type: object
          nullable: true
          properties:
            earliest: { type: string, format: date }
            latest: { type: string, format: date }
        last_sync: { type: string, format: date-time, nullable: true }
        quality_score: { type: integer, nullable: true, minimum: 0, maximum: 100 }
        status: { type: string, enum: [ready, processing] }
        connected_at: { type: string, format: date-time }
        service_address:
          type: object
          description: Sandbox only — simulated address
          properties:
            line1: { type: string }
            city: { type: string }
            province: { type: string }
            postal_code_fsa: { type: string }
            country: { type: string }
        rate_plan: { type: string, description: Sandbox only, example: TOU }
        meter_type: { type: string, description: Sandbox only, example: smart }

    UsageReading:
      type: object
      required: [timestamp, kwh, quality]
      properties:
        timestamp: { type: string, description: 'Interval start, ISO 8601 America/Toronto local time with offset', example: '2026-01-15T18:00:00-05:00' }
        kwh: { type: number, example: 1.284 }
        quality: { type: string, enum: [actual, estimated, interpolated, missing], description: Sandbox readings are always `actual` }

    EnergyProfile:
      type: object
      required: [environment, simulated, account_id, utility, timezone, interval, period, usage, summary, metadata]
      properties:
        environment: { type: string, enum: [sandbox, live] }
        simulated: { type: boolean }
        account_id: { type: string, format: uuid }
        utility: { $ref: '#/components/schemas/UtilityCode' }
        timezone: { type: string, example: America/Toronto }
        interval: { type: string, enum: [1h, 1d, 1mo], description: Matches granularity }
        period:
          type: object
          description: The returned window
          properties:
            start: { type: string, format: date }
            end: { type: string, format: date, description: Inclusive }
            total_days: { type: integer }
        usage:
          type: array
          items: { $ref: '#/components/schemas/UsageReading' }
        summary:
          type: object
          description: Computed over the full available history
          properties:
            annual_kwh: { type: number }
            avg_daily_kwh: { type: number }
            peak_hour: { type: integer, minimum: 0, maximum: 23, description: Local hour of day with the highest average usage }
            peak_month: { type: integer, minimum: 1, maximum: 12, description: Month with the highest total usage }
            seasonal_profile:
              type: object
              description: Average kWh per hourly reading in each season
              properties:
                summer_avg_kwh: { type: number, description: Jun–Aug }
                winter_avg_kwh: { type: number, description: Dec–Feb }
                shoulder_avg_kwh: { type: number, description: Mar–May and Sep–Nov }
            load_shape: { type: string, enum: [standard_residential, ev_dominant, heat_pump, high_daytime, high_evening, unknown] }
            data_quality_score: { type: integer, minimum: 0, maximum: 100 }
        metadata:
          type: object
          properties:
            service_points: { type: array, items: { type: string } }
            last_updated: { type: string, format: date-time }
            data_quality_flags:
              type: object
              properties:
                total_readings: { type: integer }
                actual_readings: { type: integer }
                estimated_readings: { type: integer }
                interpolated_readings: { type: integer }
                missing_readings: { type: integer }
                gap_periods:
                  type: array
                  items:
                    type: object
                    properties:
                      start: { type: string, format: date-time }
                      end: { type: string, format: date-time }
                      duration_hours: { type: number }
        sandbox_household:
          type: object
          description: Sandbox only — the simulated household behind the data
          properties:
            archetype: { type: string, enum: [gas_heated, electric_heated, heat_pump] }
            has_ev: { type: boolean }
            has_central_ac: { type: boolean }
            climate_region: { type: string, enum: [gta, east, central, southwest, north] }
