> ## Documentation Index
> Fetch the complete documentation index at: https://www.hedra.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Get Balance



## OpenAPI

````yaml /openapi-v3.json get /balance
openapi: 3.1.0
info:
  title: Hedra API v3
  description: >-
    Every leading image, video, and audio model behind one endpoint, one key,
    and one bill. Browse the [model
    catalog](https://www.hedra.com/develop/models), then ship with the same key.


    **Authenticate** every request with your API key — create one in the
    [developer console](https://www.hedra.com/develop/api-keys): `Authorization:
    Key <key_id>:<secret>`.


    **Quickstart** — submit a job, poll it, then read the output URL:


    ```bash

    # 1. Submit — capture the job id from the 202 ack

    JOB_ID=$(curl -sS -X POST https://api.hedra.com/v3/models/gpt-image-2 \
      -H "Authorization: Key $HEDRA_KEY" \
      -H "Content-Type: application/json" \
      -d '{"input": {"prompt": "a space cat", "quality": "medium", "aspect_ratio": "1:1", "resolution": "1K"}}' | jq -r .job_id)

    # 2. Poll until status is COMPLETED (or FAILED)

    curl https://api.hedra.com/v3/jobs/$JOB_ID/status -H "Authorization: Key
    $HEDRA_KEY"


    # 3. Read the result — outputs[].url holds the generated file

    curl https://api.hedra.com/v3/jobs/$JOB_ID -H "Authorization: Key
    $HEDRA_KEY"

    ```


    **Find a model** — `GET /models` lists every model with its `id` and human
    `name`; each operation in the *Run a model* section is titled with both, so
    a model you know by name ("GPT Image 2") maps straight to the id you submit
    to (`gpt-image-2`). One model's typed input schema: `GET
    /models/{id}/openapi.json`.


    Estimate cost before you run with `POST /models/{model}/estimate`, and check
    your balance at `GET /balance`. For volume pricing and custom limits, [talk
    to us](https://www.hedra.com/enterprise).


    The full machine-readable spec is at
    `https://api.hedra.com/v3/openapi.json`.
  version: 3.0.0
servers:
  - url: https://api.hedra.com/v3
security: []
tags:
  - name: Models
    description: Discover models and estimate cost.
  - name: Run a model
    description: >-
      One typed submit endpoint per model — titled `Run <name> (<id>)`, so the
      model you know by name is the operation you call. Every one returns the
      same `202` job envelope; track it under Jobs.
  - name: Jobs
    description: Poll, stream, and list submitted jobs.
  - name: Access
    description: API keys, browser tokens, and file uploads.
  - name: Billing
    description: Balance and usage, in US dollars.
  - name: Webhooks
    description: >-
      Configure, test, verify, and inspect outbound webhooks.


      **Delivery and retries.** Delivery is at-least-once. A `2xx` from your
      endpoint is success; anything else — including a redirect, which is never
      followed — is retried. Hedra makes up to **12 attempts over approximately
      6 hours**, backing off 10s, 30s, 90s, 4m30s, 13m30s, 40m30s, then hourly.
      An endpoint that is unreachable for the whole window is marked `FAILED`
      and is not retried again; replay it with `POST
      /webhooks/deliveries/{job_id}/redeliver`. Two failures are permanent and
      stop the ladder immediately: a URL that resolves to a blocked address
      range, and a redirect pointing at one.


      Because the retry window is bounded, respond quickly and do your own
      processing asynchronously — acknowledge with a `2xx` first.


      **Deduplication.** Key on **`X-Hedra-Webhook-Id`**. It identifies the
      *event* — it is the job's own id, and it is byte-identical across every
      retry and every replay. Do **not** hash the request body: each attempt
      re-signs the output URL, so the body legitimately differs between attempts
      of the same event and a body hash would treat every retry as new.


      Each POST also carries `X-Hedra-Webhook-Attempt` (1-based, informational)
      and **`X-Hedra-Webhook-Redelivery`** (`true` / `false`). Redelivery is the
      exception to deduplication: when it is `true`, an operator asked for this
      event to be sent again — process it even if you have already recorded that
      id, because that request is the whole reason it was sent.


      **Inspecting deliveries.** `GET /webhooks/deliveries` reports each
      delivery's cumulative `attempts`, its latest outcome (`status`,
      `last_response_status`, `last_error`), and its replay history:
      `redelivery_count` counts operator replays, and `redeliveries` archives
      the outcome each replay superseded — a replay re-sends on the same
      delivery record and never erases the previous result. Each entry's
      `attempts` value is the cumulative total when that replay was requested,
      so the per-cycle attempt counts (and any total beyond the 12-attempt
      ladder) can be read off the record directly.


      **Why a delivery failed.** `last_error` — on the delivery and on every
      archived replay entry — is the same error envelope a failed job returns
      from `GET /jobs/{job_id}`, not a free-text diagnostic. Its `code` comes
      from the one error vocabulary this API uses, narrowed for delivery to this
      set:


      | `code` | Means |

      | --- | --- |

      | `DEADLINE_EXCEEDED` | Your endpoint did not respond before the delivery
      timeout. |

      | `UNAVAILABLE` | Your endpoint could not be reached (DNS, TLS, or
      connection failure) or answered `5xx` — or redirected, which is never
      followed. |

      | `RESOURCE_EXHAUSTED` | Your endpoint answered `429`. |

      | `UNAUTHORIZED` | Your endpoint answered `401`. |

      | `PERMISSION_DENIED` | Your endpoint answered `403`. |

      | `NOT_FOUND` | Your endpoint answered `404`. |

      | `INVALID_ARGUMENT` | Your endpoint answered some other `4xx`. |

      | `FAILED_PRECONDITION` | The URL is not a permitted webhook target — it
      resolves to a blocked address range, or redirects to one. Fix the URL;
      replaying will not help. |

      | `INTERNAL` | Hedra could not build the payload. |

      | `UNKNOWN` | No classification is available, including for deliveries
      that failed before this field became structured. |


      `retryable` describes the condition, not what Hedra did: every non-2xx
      response is retried on the ladder above, so it answers whether replaying
      the delivery is likely to help. The `message` is a fixed summary — your
      endpoint's URL, address, headers, credentials, and response body are never
      echoed back, so treat your own logs, not this field, as the record of what
      your endpoint returned.


      **Verifying a delivery.** Every request carries
      `X-Hedra-Webhook-Signature`, a hex-encoded ed25519 signature over a
      canonical string of **five newline-separated fields, in this order**:


      ```

      {X-Hedra-Webhook-Timestamp}

      {X-Hedra-Webhook-Id}

      {X-Hedra-Webhook-Event}

      {X-Hedra-Webhook-Redelivery}

      {sha256 hex digest of the raw request body}

      ```


      Rebuild that string from the request you received, verify it against the
      key from `GET /webhooks/public-key`, and reject the delivery if the
      timestamp is more than 5 minutes old. Hash the body **exactly as
      received**, before any JSON parsing or re-serialization.


      The signature deliberately covers the deduplication id and the redelivery
      flag, not just the body — both of them decide whether you process a
      duplicate, so an unsigned copy of either would let anyone who captured a
      delivery replay it past your idempotency check. Verify *before* you act on
      any header. `X-Hedra-Webhook-Attempt` is outside the signature and is
      informational only; do not branch on it.
  - name: Log drains
    description: >-
      Stream signed job lifecycle logs to HTTPS destinations.


      **Why the last batch failed.** `last_error` is the same error envelope a
      failed job returns from `GET /jobs/{job_id}`, not a free-text diagnostic.
      It is null until a batch fails and is cleared again on the next success.
      Its `code` comes from the one error vocabulary this API uses, narrowed for
      drain delivery to this set:


      | `code` | Means |

      | --- | --- |

      | `DEADLINE_EXCEEDED` | Your destination did not respond before the
      delivery timeout. |

      | `UNAVAILABLE` | Your destination could not be reached (DNS, TLS, or
      connection failure) or answered `5xx` — or redirected, which is never
      followed. |

      | `RESOURCE_EXHAUSTED` | Your destination answered `429`. |

      | `UNAUTHORIZED` | Your destination answered `401`. |

      | `PERMISSION_DENIED` | Your destination answered `403`. |

      | `NOT_FOUND` | Your destination answered `404`. |

      | `INVALID_ARGUMENT` | Your destination answered some other `4xx`. |

      | `FAILED_PRECONDITION` | The URL is not a permitted log drain target — it
      resolves to a blocked address range. Fix the URL; retrying will not help.
      |

      | `INTERNAL` | Hedra could not prepare the batch. |

      | `UNKNOWN` | No classification is available, including for drains that
      last failed before this field became structured. |


      `retryable` describes the condition, not what Hedra did: every failed
      batch is requeued until the drain auto-disables, so it answers whether
      fixing the destination and re-enabling is likely to help. The `message` is
      a fixed summary — your destination's URL, headers, credentials, and
      response body are never echoed back, so treat your own logs, not this
      field, as the record of what your destination returned.


      `disabled_reason` answers a different question and stays its own small
      vocabulary: `consecutive_failures` when five batches in a row failed,
      `disabled_by_user` when you turned the drain off.
paths:
  /balance:
    get:
      tags:
        - Billing
      summary: Get Balance
      operationId: billing_get_balance
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BalanceResponse'
        '400':
          description: Invalid or failed-validation request.
          content:
            application/json:
              examples:
                field_validation:
                  summary: A submit `input` field failed schema validation
                  value:
                    error:
                      code: INVALID_ARGUMENT
                      message: 2 input fields are invalid; see details.
                      retryable: false
                      param: input.aspect_ratio
                      details:
                        - field: input.aspect_ratio
                          message: >-
                            input.aspect_ratio: Input should be '16:9', '9:16'
                            or '1:1'
                          reason: enum
                        - field: input.duration_ms
                          message: >-
                            input.duration_ms: Input should be greater than or
                            equal to 1000
                          reason: minimum
                expired_file_handle:
                  summary: >-
                    A `POST /v3/files` handle was submitted past its
                    `expires_at`
                  value:
                    error:
                      code: INVALID_ARGUMENT
                      message: >-
                        input.images[0]: the POST /v3/files url expired at
                        2026-07-28T23:31:12+00:00. Upload the file again and
                        submit with the fresh url.
                      retryable: false
                      param: input.images[0]
                      details:
                        - field: input.images[0]
                          message: >-
                            input.images[0]: the POST /v3/files url expired at
                            2026-07-28T23:31:12+00:00. Upload the file again and
                            submit with the fresh url.
                          reason: expired
                unsupported_model:
                  summary: The model exists but isn't wired to v3 yet
                  value:
                    error:
                      code: FAILED_PRECONDITION
                      message: Model 'some-model' (VIDEO) is not supported on v3 yet.
                      retryable: false
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: 'Authentication failed: missing, malformed, or invalid credentials.'
          content:
            application/json:
              examples:
                missing_credentials:
                  summary: No credential on the request
                  value:
                    error:
                      code: UNAUTHORIZED
                      message: >-
                        Missing credentials. Send 'Authorization: Key
                        <key_id>:<secret>' or 'Authorization: Bearer <token>'.
                      retryable: false
                invalid_key:
                  summary: Credential present but not valid
                  value:
                    error:
                      code: UNAUTHORIZED
                      message: Invalid API key.
                      retryable: false
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Insufficient scope or plan.
          content:
            application/json:
              examples:
                no_api_access:
                  summary: Authenticated, but the plan excludes API access
                  value:
                    error:
                      code: PERMISSION_DENIED
                      message: This key's plan does not include API access.
                      retryable: false
                missing_scope:
                  summary: Key lacks a scope the operation requires
                  value:
                    error:
                      code: PERMISSION_DENIED
                      message: 'Key is missing required scope(s): jobs:write.'
                      retryable: false
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Resource not found.
          content:
            application/json:
              examples:
                job_not_found:
                  summary: Unknown job id, or not visible to the caller
                  value:
                    error:
                      code: NOT_FOUND
                      message: Job not found.
                      retryable: false
                model_not_found:
                  summary: No such model in the catalog
                  value:
                    error:
                      code: NOT_FOUND
                      message: Model 'some-model' not found.
                      retryable: false
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limit exceeded.
          content:
            application/json:
              examples:
                rate_limited:
                  summary: Per-key request rate limit exceeded
                  value:
                    error:
                      code: RESOURCE_EXHAUSTED
                      message: Request rate limit exceeded. Retry after 30s.
                      retryable: true
                      retry_after: 30
              schema:
                $ref: '#/components/schemas/ErrorResponse'
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
            X-RateLimit-Limit:
              description: Request budget for the current window.
              schema:
                type: integer
            X-RateLimit-Remaining:
              description: Requests left in the current window.
              schema:
                type: integer
            X-RateLimit-Reset:
              description: Unix epoch second the window resets at.
              schema:
                type: integer
        '500':
          description: Internal error.
          content:
            application/json:
              examples:
                internal:
                  summary: Unexpected server error; internals are never leaked
                  value:
                    error:
                      code: INTERNAL
                      message: An unexpected error occurred
                      retryable: false
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      security:
        - KeyAuth: []
        - BearerToken: []
components:
  schemas:
    BalanceResponse:
      properties:
        balance:
          type: number
          title: Balance
          description: Spendable balance.
        spent:
          anyOf:
            - type: number
            - type: 'null'
          title: Spent
          description: >-
            Amount consumed in the current billing period; null for the
            non-expiring API wallet (see GET /v3/usage).
        currency:
          type: string
          title: Currency
          description: ISO-4217 currency code for every amount in this response.
      type: object
      required:
        - balance
        - currency
      title: BalanceResponse
      description: >-
        Spendable balance on the account this key bills.


        Sourced from the workspace wallet for a workspace-bound key, else the

        caller's personal balance. While the segregated API wallet is enabled,
        both

        key types instead read the API wallet that generation debits draw on (a

        personal key resolves its bound workspace).


        Every amount is in US dollars.
    ErrorResponse:
      properties:
        error:
          $ref: '#/components/schemas/ErrorEnvelope'
        trace_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Trace Id
      type: object
      required:
        - error
      title: ErrorResponse
      description: 'Top-level error body: ``{"error": {...}}`` plus a debug id.'
    ErrorEnvelope:
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          title: Message
        retryable:
          type: boolean
          title: Retryable
          default: false
        retry_after:
          anyOf:
            - type: integer
            - type: 'null'
          title: Retry After
        param:
          anyOf:
            - type: string
            - type: 'null'
          title: Param
        details:
          anyOf:
            - items:
                $ref: '#/components/schemas/FieldError'
              type: array
            - type: 'null'
          title: Details
        replaced_by:
          anyOf:
            - type: string
            - type: 'null'
          title: Replaced By
      type: object
      required:
        - code
        - message
      title: ErrorEnvelope
    ErrorCode:
      type: string
      enum:
        - UNKNOWN
        - INVALID_ARGUMENT
        - NOT_FOUND
        - GONE
        - ALREADY_EXISTS
        - ALREADY_IN_PROGRESS
        - UNAUTHORIZED
        - PERMISSION_DENIED
        - INSUFFICIENT_BALANCE
        - MODERATION_FAILED
        - FAILED_PRECONDITION
        - DEADLINE_EXCEEDED
        - RESOURCE_EXHAUSTED
        - UNAVAILABLE
        - INTERNAL
      title: ErrorCode
      description: >-
        Universal error code space. Modeled after gRPC status codes.


        Every exception in the system carries an ErrorCode. SDK clients map

        3rd-party errors to these codes at the lowest level. Retry logic,

        HTTP status mapping, and OTEL metrics all key off this enum.


        NOTE: The semantic meaning of these error codes is roughly mapped from
        the
            semantic of the gRPC status codes: see
            https://grpc.io/docs/guides/status-codes/ for details.
    FieldError:
      properties:
        field:
          type: string
          title: Field
        message:
          type: string
          title: Message
        reason:
          anyOf:
            - type: string
            - type: 'null'
          title: Reason
        allowed:
          anyOf:
            - items:
                type: string
              type: array
            - type: 'null'
          title: Allowed
      type: object
      required:
        - field
        - message
      title: FieldError
      description: >-
        One field-level validation problem in a request body.


        `field` is the dotted path to the offending input (e.g.
        `input.resolution`);

        `allowed` lists the accepted values when the field is an enum, so a
        caller

        can fix it without re-fetching the model schema.
  securitySchemes:
    KeyAuth:
      type: apiKey
      in: header
      name: Authorization
      description: 'Primary scheme: `Authorization: Key <key_id>:<secret>`.'
    BearerToken:
      type: http
      scheme: bearer
      description: >-
        `Authorization: Bearer <credential>` — accepts an API key
        (`<key_id>:<secret>`) or an ephemeral browser token minted via `POST
        /tokens` (inherits the minting key's scopes and workspace).

````