> ## 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 Job



## OpenAPI

````yaml /openapi-v3.json get /jobs/{job_id}
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:
  /jobs/{job_id}:
    get:
      tags:
        - Jobs
      summary: Get Job
      operationId: jobs_get
      parameters:
        - name: job_id
          in: path
          required: true
          schema:
            type: string
            title: Job Id
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResultResponse'
        '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:
    ResultResponse:
      properties:
        job_id:
          type: string
          title: Job Id
        model:
          type: string
          title: Model
        quality:
          anyOf:
            - type: string
            - type: 'null'
          title: Quality
          description: >-
            The quality level this job ran at; present only for models that
            offer quality levels.
        status:
          $ref: '#/components/schemas/JobStatus'
        prompt:
          anyOf:
            - type: string
            - type: 'null'
          title: Prompt
          description: >-
            The prompt this job ran with. When `enhance_prompt` was set, this is
            the rewritten prompt the model received rather than the one
            submitted. Absent on models that take no prompt.
        outputs:
          items:
            $ref: '#/components/schemas/OutputItem'
          type: array
          title: Outputs
        metrics:
          anyOf:
            - $ref: '#/components/schemas/Metrics'
            - type: 'null'
          description: Timing for this job; present on completed jobs only.
        error:
          anyOf:
            - $ref: '#/components/schemas/ErrorEnvelope'
            - type: 'null'
        logs:
          anyOf:
            - items:
                $ref: '#/components/schemas/JobLogItem'
              type: array
            - type: 'null'
          title: Logs
          description: >-
            The most recent lifecycle events for this job, oldest first. Capped;
            GET /v3/jobs/{job_id}/logs serves the full paginated history. Absent
            from webhook payloads.
        cost:
          anyOf:
            - type: number
            - type: 'null'
          title: Cost
          description: >-
            Net cost of this job; 0 when fully refunded; absent until charged.
            Absent from webhook payloads.
        currency:
          anyOf:
            - type: string
            - type: 'null'
          title: Currency
          description: ISO-4217 currency code for `cost`. Present exactly when `cost` is.
      type: object
      required:
        - job_id
        - model
        - status
      title: ResultResponse
    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.'
    JobStatus:
      type: string
      enum:
        - IN_QUEUE
        - IN_PROGRESS
        - COMPLETED
        - FAILED
      title: JobStatus
      description: The four states a job can be in.
    OutputItem:
      properties:
        status:
          $ref: '#/components/schemas/OutputStatus'
          default: COMPLETED
        asset_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Asset Id
          description: >-
            This output's asset id — server-issued, and opaque. Pass it as
            `{"source": "asset", "asset_id": ...}` in a later submit's media
            inputs to reuse this output as a reference. Null once the output has
            expired, since its bytes are no longer retrievable.
        url:
          anyOf:
            - type: string
            - type: 'null'
          format: uri
          title: Url
        content_type:
          anyOf:
            - type: string
            - type: 'null'
          title: Content Type
        width:
          anyOf:
            - type: integer
            - type: 'null'
          title: Width
        height:
          anyOf:
            - type: integer
            - type: 'null'
          title: Height
        duration_ms:
          anyOf:
            - type: integer
            - type: 'null'
          title: Duration Ms
        fps:
          anyOf:
            - type: integer
            - type: 'null'
          title: Fps
        error:
          anyOf:
            - $ref: '#/components/schemas/ErrorEnvelope'
            - type: 'null'
      type: object
      title: OutputItem
      description: |-
        One item of a job's `outputs[]` (`GET /v3/jobs/{job_id}`). `outputs` is
        always an array, even for a single output.

        Every key is always present. The ones a modality carries no value for
        serialize as null — an image output reports `duration_ms: null` and
        `fps: null`, an audio output `width: null` — so the shape is one object
        rather than one per modality. `url` and `asset_id` go null on an expired
        output, which has metadata but no retrievable bytes.
    Metrics:
      properties:
        processing_time_ms:
          anyOf:
            - type: integer
            - type: 'null'
          title: Processing Time Ms
          description: >-
            Wall-clock milliseconds between this job's `started` and `completed`
            lifecycle events. It brackets provider queueing, generation, output
            download, and any failed attempts with their retry backoff, so it
            measures the whole job rather than the model's own inference time,
            and it is not a provider-reported figure. Read from the job's
            durable lifecycle records, so polled results and webhook deliveries
            report the same value. Null when the job did not record both events.
      type: object
      title: Metrics
      description: Timing measured for a completed job.
    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
    JobLogItem:
      properties:
        id:
          type: integer
          title: Id
        timestamp:
          type: string
          format: date-time
          title: Timestamp
        level:
          $ref: '#/components/schemas/JobLogLevel'
        event:
          $ref: '#/components/schemas/JobLogEvent'
        message:
          type: string
          title: Message
        source:
          $ref: '#/components/schemas/JobLogSource'
        data:
          additionalProperties: true
          type: object
          title: Data
      type: object
      required:
        - id
        - timestamp
        - level
        - event
        - message
        - source
      title: JobLogItem
      description: One customer-visible lifecycle event for a v3 job.
    OutputStatus:
      type: string
      enum:
        - COMPLETED
        - FAILED
        - EXPIRED
      title: OutputStatus
      description: The state of a single output within a job's result.
    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.
    JobLogLevel:
      type: string
      enum:
        - info
        - warning
        - error
      title: JobLogLevel
    JobLogEvent:
      type: string
      enum:
        - queued
        - started
        - moderation.passed
        - provider.submitted
        - provider.error
        - retry.scheduled
        - progress
        - finalizing
        - download.ready
        - completed
        - failed
        - recovered
      title: JobLogEvent
    JobLogSource:
      type: string
      enum:
        - api
        - worker
        - provider
        - cron
      title: JobLogSource
  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).

````