openapi: 3.1.0
info:
  title: Energy1Power API
  version: 1.1.0-preview
  summary: One integration for EPCs, utilities, DERMS, inverters, and batteries.
  description: |
    Public product and partner API served at https://api.energy1power.io.
    `/v1` holds until a breaking change; additive fields never create a new version.
    Money, dispatch and ingest POSTs accept `Idempotency-Key` (same key + same body replays the
    stored response with `Idempotent-Replayed: true`; same key + different body is 422).
    All timestamps are RFC 3339 with offset.

    Authentication: partners and integrators use `Authorization: Bearer e1p_sbx_…` API keys
    (scoped, expiring, created in the workspace with MFA). The workspace at app.energy1power.com
    uses a host-only session cookie; session-only routes are marked below.
  contact: { name: ENERGY1POWER Developers, email: developers@energy1power.com, url: https://developers.energy1power.io }
servers:
  - url: https://api.energy1power.io
    description: Production
  - url: https://api.staging.energy1power.io
    description: Staging / sandbox ISO
security:
  - bearer: []
tags:
  - { name: system }
  - { name: access }
  - { name: assets }
  - { name: market }
  - { name: email }
  - { name: auth }
  - { name: orgs }
  - { name: api-keys }
  - { name: telemetry }
paths:
  /healthz:
    get:
      tags: [system]
      security: []
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Health" } } } }
  /v1/access-requests:
    post:
      tags: [access]
      security: []
      summary: Request platform access (from www.energy1power.com)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/AccessRequest" } } }
      responses:
        "202": { description: Received }
        "422": { $ref: "#/components/responses/Validation" }
  /v1/assets:
    get:
      tags: [assets]
      summary: List org-scoped assets
      responses:
        "200": { description: Assets, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Asset" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [assets]
      summary: Enroll a resource
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/AssetIn" } } }
      responses:
        "201": { description: Enrolled, content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Validation" }
  /v1/assets/{id}:
    get:
      tags: [assets]
      summary: Asset 360 (includes the latest telemetry point)
      parameters: [{ $ref: "#/components/parameters/Id" }]
      responses:
        "200": { description: Asset, content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } } }
        "404": { description: Not found (or not in your organization) }
  /v1/assets/{id}/telemetry:
    get:
      tags: [telemetry]
      summary: Telemetry series (default last 24 h, oldest first). Scope telemetry:read.
      parameters:
        - { $ref: "#/components/parameters/Id" }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 10000, default: 1440 } }
      responses:
        "200": { description: Points, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/TelemetryPoint" } } } } }
  /v1/telemetry:
    post:
      tags: [telemetry]
      summary: Push up to 5,000 interval points. Scope telemetry:write.
      description: Points for assets outside your organization are rejected (422 unknown_asset). On the live path, measured points arriving more than 5 minutes late are stored as stale; set backfill=true for historical uploads.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [points]
              properties:
                backfill: { type: boolean, default: false }
                points: { type: array, minItems: 1, maxItems: 5000, items: { $ref: "#/components/schemas/TelemetryPoint" } }
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema: { type: object, properties: { accepted: { type: integer }, duplicates: { type: integer }, stale: { type: integer } } }
        "422": { description: Validation failed, unknown_asset, or timestamp_in_future }
  /v1/api-keys:
    get:
      tags: [api-keys]
      summary: List keys (session only; owners/admins)
      security: [{ session: [] }]
      responses:
        "200": { description: Keys (secrets are never returned) }
    post:
      tags: [api-keys]
      summary: Create a sandbox key (session only; requires fresh MFA)
      security: [{ session: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name: { type: string, maxLength: 100 }
                scopes: { type: array, items: { $ref: "#/components/schemas/Scope" }, minItems: 1 }
                environment: { type: string, enum: [sandbox], default: sandbox }
                expires_in_days: { type: integer, minimum: 1, maximum: 365, default: 90 }
      responses:
        "201": { description: Created; the `key` field is shown exactly once }
        "403": { description: mfa_required, forbidden, or live_keys_not_available }
  /v1/api-keys/{id}:
    delete:
      tags: [api-keys]
      summary: Revoke a key immediately (session only; requires fresh MFA)
      security: [{ session: [] }]
      parameters: [{ $ref: "#/components/parameters/Id" }]
      responses:
        "200": { description: Revoked }
  /v1/orgs:
    post:
      tags: [orgs]
      summary: Create an organization; the caller becomes owner (session only)
      security: [{ session: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, kind]
              properties:
                name: { type: string, minLength: 2, maxLength: 200 }
                kind: { type: string, enum: [asset_owner, epc, partner] }
      responses:
        "201": { description: Created }
  /v1/me:
    get:
      tags: [auth]
      summary: Current user, organizations, roles and MFA state (session only)
      security: [{ session: [] }]
      responses:
        "200": { description: Me }
  /v1/auth/magic-link:
    post:
      tags: [auth]
      security: []
      summary: Email a one-time sign-in link (always 202; rate limited per address)
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email], properties: { email: { type: string, format: email } } } } }
      responses:
        "202": { description: Sent if allowed }
        "503": { description: email_not_configured }
  /v1/auth/magic-link/consume:
    post:
      tags: [auth]
      security: []
      summary: Exchange a sign-in token for a session cookie
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [token], properties: { token: { type: string } } } } }
      responses:
        "200": { description: Signed in (Set-Cookie) }
        "410": { description: link_expired_or_used }
  /v1/auth/google/start:
    get:
      tags: [auth]
      security: []
      summary: Begin Google OIDC sign-in (PKCE + nonce); redirects to Google
      responses:
        "302": { description: Redirect }
  /v1/auth/totp/enroll:
    post:
      tags: [auth]
      security: [{ session: [] }]
      summary: Start authenticator enrollment; returns the secret and otpauth URI
      responses:
        "200": { description: Secret }
        "409": { description: already_enrolled }
  /v1/auth/totp/verify:
    post:
      tags: [auth]
      security: [{ session: [] }]
      summary: Confirm enrollment with a 6-digit code (marks this session MFA-fresh)
      responses:
        "200": { description: Verified }
        "401": { description: invalid_code }
  /v1/auth/totp/challenge:
    post:
      tags: [auth]
      security: [{ session: [] }]
      summary: Step-up with a 6-digit code (codes cannot be replayed)
      responses:
        "200": { description: Verified }
        "401": { description: invalid_code }
  /v1/auth/logout:
    post:
      tags: [auth]
      security: [{ session: [] }]
      responses:
        "200": { description: Session revoked }
  /v1/portfolio:
    get:
      tags: [assets]
      summary: MW / GW rollups by asset type and market
      responses:
        "200": { description: Portfolio, content: { application/json: { schema: { $ref: "#/components/schemas/Portfolio" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/market/listings:
    get:
      tags: [market]
      security: [{}, { bearer: [] }]
      summary: Energy1Power Market listings (guests receive redacted depth)
      responses:
        "200": { description: Listings, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Listing" } } } } }
    post:
      tags: [market]
      summary: List flexibility backed by enrolled assets
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/ListingIn" } } }
      responses:
        "201": { description: Listed, content: { application/json: { schema: { $ref: "#/components/schemas/Listing" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { description: Validation failed or MW exceeds physical capacity }
  /v1/email/preferences:
    get:
      tags: [email]
      summary: Read consent state for the signed-in user
      responses:
        "200": { description: Preferences }
    put:
      tags: [email]
      summary: Update operational / marketing consent (transactional is locked while the org is active)
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [operational, marketing], properties: { operational: { type: boolean }, marketing: { type: boolean } } }
      responses:
        "200": { description: Saved }
components:
  securitySchemes:
    bearer: { type: http, scheme: bearer, description: "Partner API key: e1p_sbx_<prefix>_<secret>" }
    session: { type: apiKey, in: cookie, name: __Host-e1p_session, description: Workspace session (app.energy1power.com only) }
  parameters:
    Id:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, maxLength: 128 }
  responses:
    Unauthorized: { description: Authentication required }
    Forbidden: { description: Role or org scope does not permit this action }
    Validation: { description: Request failed validation }
  schemas:
    Health:
      type: object
      properties: { ok: { type: boolean }, service: { type: string }, version: { type: string } }
    AccessRequest:
      type: object
      required: [name, email, company, role]
      properties:
        name: { type: string, maxLength: 200 }
        email: { type: string, format: email }
        company: { type: string, maxLength: 200 }
        role: { type: string, enum: [asset-owner, epc-developer, utility-partner, integrator, investor, other] }
        portfolio_mw: { type: number, minimum: 0 }
        message: { type: string, maxLength: 4000 }
        operational_opt_in: { type: boolean, default: false }
        marketing_opt_in: { type: boolean, default: false }
    AssetType: { type: string, enum: [solar, bess, evse, load, gen, microgrid] }
    Market: { type: string, enum: [CAISO, ERCOT, PJM, NYISO, ISO-NE, MISO, SPP, UTILITY] }
    Scope: { type: string, enum: ["assets:read", "assets:write", "telemetry:write", "telemetry:read", "market:read", "market:write"] }
    TelemetryPoint:
      type: object
      required: [asset_id, ts]
      properties:
        asset_id: { type: string, format: uuid }
        ts: { type: string, format: date-time }
        kw: { type: [number, "null"], description: "+ export / discharge, - import / charge" }
        soc_kwh: { type: [number, "null"] }
        quality: { type: string, enum: [measured, estimated, stale, interpolated], default: measured }
    AssetIn:
      type: object
      required: [site_name, type, capacity_kw, market]
      description: The organization comes from the caller (session org or API key). BESS requires energy_kwh; UTILITY requires timezone.
      properties:
        site_name: { type: string }
        type: { $ref: "#/components/schemas/AssetType" }
        capacity_kw: { type: number, exclusiveMinimum: 0 }
        energy_kwh: { type: number, exclusiveMinimum: 0, description: Required for bess }
        market: { $ref: "#/components/schemas/Market" }
        node: { type: string }
        utility: { type: string }
        oem: { type: string }
        timezone: { type: string, description: IANA zone (defaults from market) }
        lat: { type: number }
        lon: { type: number }
    Asset:
      allOf:
        - $ref: "#/components/schemas/AssetIn"
        - type: object
          required: [id, status, created_at]
          properties:
            id: { type: string, format: uuid }
            org_id: { type: string, format: uuid }
            site_id: { type: string, format: uuid }
            status: { type: string, enum: [enrolled, commissioning, active, suspended] }
            created_at: { type: string, format: date-time }
    Portfolio:
      type: object
      properties:
        asset_count: { type: integer }
        capacity_mw: { type: number }
        capacity_gw: { type: number }
        storage_mwh: { type: number }
        mw_by_type: { type: object, additionalProperties: { type: number } }
        mw_by_market: { type: object, additionalProperties: { type: number } }
    ListingIn:
      type: object
      required: [product, mw, market, location, window_start, window_end, asset_ids]
      properties:
        product: { type: string, enum: [energy, capacity, shed, reserves, black-start] }
        mw: { type: number, exclusiveMinimum: 0 }
        mwh: { type: number }
        market: { $ref: "#/components/schemas/Market" }
        location: { type: string }
        window_start: { type: string, format: date-time }
        window_end: { type: string, format: date-time }
        asset_ids: { type: array, items: { type: string, format: uuid }, minItems: 1 }
        min_price_per_mwh: { type: number }
    Listing:
      allOf:
        - $ref: "#/components/schemas/ListingIn"
        - type: object
          properties:
            id: { type: string }
            org_id: { type: string }
            status: { type: string, enum: [draft, listed, matched, withdrawn] }
            created_at: { type: string, format: date-time }
