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

# Get a job

> Get image or video generation status and results.

Returns the current job state, exact billing fields, and short-lived signed output URLs when available.


## OpenAPI

````yaml GET /v1/jobs/{job_id}
openapi: 3.1.0
info:
  title: NSFW API
  description: >

    Asynchronous image and video generation for approved adult-content
    businesses. Every request is authenticated with a project API key, priced in
    USD before it runs, and answered with a job you poll or receive by signed
    webhook.


    ## Quickstart


    Create a project and an API key in the dashboard, then generate an image:


    ```shell

    curl https://nsfw-api.net/v1/images/generations \
      -H "Authorization: Bearer $NSFW_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"model":"z-image-spicy","prompt":"A lighthouse at sunrise"}'
    ```


    The response is HTTP `202` with a job ID and the amount reserved from your
    balance. Poll `GET /v1/jobs/{job_id}` until `status` is `succeeded`,
    `failed`, `cancelled`, or `expired`.


    ## Authentication


    Send your key as a bearer token on every request:


    ```http

    Authorization: Bearer nsfw_live_<key_id>.<secret>

    ```


    The raw key is shown once, at creation. Store it in a secret manager, never
    in client-side code or a public repository. Keys carry scopes
    (`models:read`, `generations:write`, `jobs:read`); a request without the
    required scope returns `403 permission_denied`. Revoke a leaked key in the
    dashboard; revocation takes effect immediately.


    ## Models and pricing


    `GET /v1/models` returns every model you may call, its capabilities, and its
    price. Prices are in USD with four decimal places, and you are charged only
    for a generation that succeeds.


    | Model | Operation | Price |

    |---|---|---|

    | `z-image-spicy` | image generation | $0.0200 per image |

    | `qwen-image-edit-spicy` | image edit | $0.0400 per image |

    | `w3.0-video` | video generation | $0.0500 / $0.1000 / $0.2000 per second
    at 480p / 720p / 1080p |

    | `wan2.2-i2v-spicy` | video generation | $0.0200 / $0.0400 per second at
    480p / 720p |

    | `wan2.7-i2v-spicy` | video generation | $0.1300 / $0.2000 per second at
    720p / 1080p |

    | `w2.2-i2v-lora` | video generation | $0.0200 / $0.0400 per second at 480p
    / 720p |


    A video costs its per-second rate multiplied by `duration`. For a per-second
    model `price.amount` is `null` and the rate lives in
    `price.rates_per_second`, keyed by resolution, so a single flat price is
    never implied. Where a model accepts `prompt_extend`, enabling it adds
    $0.0100 to that request. Each request body is validated strictly against its
    model: unknown fields and fields belonging to another model are rejected
    rather than forwarded.


    ## Balance, reserve, and settlement


    Each account holds a USD balance. When a request is accepted, its maximum
    price is reserved; the reserve is visible as `reserved_amount` on the job.
    Your available balance is the balance minus everything currently reserved,
    so a reserved amount cannot be spent twice.


    On success the exact cost is charged and the reserve is cleared. On any
    failure, cancellation, timeout, or content-policy rejection the reserve is
    released in full and nothing is charged. If a reserve cannot be taken, the
    request returns `402 insufficient_balance` and no job is created.


    `GET /v1/balance` reports the balance, what is currently reserved, and what
    is available. The balance belongs to the account, so every project shares
    it. `GET /v1/usage` lists what has actually been charged for the project the
    key belongs to, filtered by `from`, `to`, and `model`; a job that failed
    never appears there, because it was never charged.


    ## Private files


    Image, video, and audio inputs may be a public HTTPS URL or a private file
    you upload to us. Uploading is three steps.


    1. `POST /v1/uploads` with `purpose`, `content_type`, and the exact
    `size_bytes`. You receive a `media_id` and a single-use `upload_url`.

    2. `PUT` the bytes to `upload_url` with the returned `required_headers`. The
    bytes never pass through the generation API.

    3. `POST /v1/uploads/{media_id}/complete`. We verify the stored object's
    real content type, size, and hash before the file can be used.


    Then pass the `media_id` wherever a media field is accepted:


    ```shell

    curl https://nsfw-api.net/v1/images/edits \
      -H "Authorization: Bearer $NSFW_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"model":"qwen-image-edit-spicy","image":"media_...","prompt":"Replace the background with a forest"}'
    ```


    Limits per file are 10 MiB for images, 50 MiB for video, and 15 MiB for
    audio, and only the content types listed on `POST /v1/uploads` are accepted.
    A `media_id` may be attached to exactly one job; reusing it returns `400
    invalid_media_input`. Uploads that are never attached expire after 24 hours.


    An account may hold up to 5 GiB of live inputs at once. An upload that has
    not been completed yet counts against that quota at the maximum size for its
    purpose, so abandoned uploads take room until they expire. Past the quota,
    `POST /v1/uploads` returns `413 storage_quota_exceeded`; complete or drop
    what you already uploaded, or wait for it to expire.


    ## Idempotency


    `Idempotency-Key` is optional and protects against an accidental retry of
    one operation, such as a client timeout:


    ```http

    Idempotency-Key: your-operation-id

    ```


    The same key with the same body returns the original job instead of creating
    and charging a second one. The same key with a different body returns `409
    idempotency_conflict`. Without the header, every request creates an
    independent job even if the body is identical.


    ## Rate limits


    Limits apply per API key, project, and account, and separately cap how many
    jobs may run at once. Exceeding a limit returns `429 rate_limit_exceeded`
    with a `Retry-After` header. Retry after the indicated delay, with
    exponential backoff.


    ## Asynchronous jobs


    Generation is never synchronous. A job moves through `queued`, `submitting`,
    and `processing` to exactly one terminal state: `succeeded`, `failed`,
    `cancelled`, or `expired`.


    `POST /v1/jobs/{job_id}/cancel` works while the job is still queued and
    returns the reserve in full. After the job has been handed to the provider
    the generation is already being paid for, so cancelling returns `409
    job_not_cancelable`; we do not promise an upstream stop we cannot guarantee.
    Cancelling a job that already finished simply reports its existing status.


    Poll `GET /v1/jobs/{job_id}` no more than once per second per job. A
    succeeded job carries `data` with a short-lived signed URL per output.
    Download those bytes to your own storage; the URLs expire, and so do the
    files behind them.


    ## Webhooks


    Instead of polling you can be notified. Persistent endpoints are configured
    per project in the dashboard. Any generation request may also pass
    `webhook_url`, which adds a callback for that one job and does not replace
    persistent endpoints; its signing secret is returned once, in the `202`
    response, as `webhook_signing_secret`.


    Every delivery carries `X-NSFW-Webhook-Id`, `X-NSFW-Webhook-Timestamp`, and
    `X-NSFW-Webhook-Signature`. The signed bytes are
    `{delivery_id}.{timestamp}.{raw_body}`, hashed with HMAC-SHA256 using your
    signing secret. Compare signatures in constant time, and verify every
    comma-separated `v1=<hex>` value, because one URL can be covered by more
    than one secret. Reject a timestamp far outside your clock skew window.


    Answer with any 2xx status. Non-2xx responses and network errors are retried
    with backoff before the delivery is marked dead, so treat
    `X-NSFW-Webhook-Id` as a deduplication key. Payloads contain freshly signed
    result URLs, subject to the same expiry as polling.


    ## Errors


    Errors share one shape and are always in English:


    ```json

    {
      "error": {
        "message": "Insufficient balance",
        "type": "billing_error",
        "param": null,
        "code": "insufficient_balance"
      },
      "request_id": "req_..."
    }

    ```


    | HTTP | `code` | Meaning |

    |---:|---|---|

    | 400 | `invalid_request` | The request body or parameters are invalid |

    | 400 | `invalid_media_input` | Media is unavailable, expired, already used,
    or the wrong type |

    | 401 | `invalid_api_key` | The key is missing, wrong, expired, or revoked |

    | 402 | `insufficient_balance` | The price could not be reserved |

    | 403 | `permission_denied` | The key lacks the scope or model access |

    | 404 | `not_found` | The resource does not exist or belongs to another
    account |

    | 409 | `idempotency_conflict` | The idempotency key was reused with a
    different body |

    | 409 | `job_not_cancelable` | The job already went to the provider |

    | 413 | `media_too_large` | The uploaded file exceeds its size limit |

    | 413 | `storage_quota_exceeded` | The account is holding its maximum volume
    of inputs |

    | 429 | `rate_limit_exceeded` | A rate or concurrency limit was hit |

    | 500 | `internal_error` | An unexpected error on our side |

    | 502 | `provider_error` | The upstream generator failed |

    | 503 | `temporarily_unavailable` | A dependency is temporarily unavailable
    |

    | 504 | `provider_timeout` | The upstream generator timed out |


    Retry `429`, `500`, `503`, and `504` with backoff. Do not retry `400`,
    `401`, `402`, `403`, or `409` unchanged. Always include `request_id` when
    contacting support.


    ## Data retention


    Inputs and generated outputs are stored in private buckets and are reachable
    only through short-lived signed URLs. Both are deleted 24 hours after a job
    reaches a terminal state, and an upload that is never attached to a job is
    deleted 24 hours after it was created. Prompts and normalized job input are
    redacted on the same 24-hour schedule; redacted diagnostic records are kept
    for 30 days.


    Download anything you need to keep. A signed URL that has expired cannot be
    renewed once the file behind it is gone, and the job then returns without
    `data`.
  version: 0.1.0
servers:
  - url: https://nsfw-api.net
security: []
tags:
  - name: Health
    description: Process and dependency health
  - name: Models
    description: Available image and video generation models
  - name: Files
    description: Private media uploads for image and video generation
  - name: Images
    description: Asynchronous image generation
  - name: Videos
    description: Asynchronous video generation
  - name: Jobs
    description: Image and video job status and results
  - name: Account
    description: Balance and charged usage
  - name: Dashboard
    description: Authenticated account and project management
paths:
  /v1/jobs/{job_id}:
    get:
      tags:
        - Jobs
      summary: Get an image or video generation job
      operationId: getGenerationJob
      parameters:
        - schema:
            type: string
            pattern: ^job_[A-Za-z0-9_-]{20,64}$
          in: path
          name: job_id
          required: true
      responses:
        '200':
          description: Default Response
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required:
                  - id
                  - object
                  - created
                  - status
                  - model
                  - poll_url
                properties:
                  id:
                    type: string
                    pattern: ^job_[A-Za-z0-9_-]{20,64}$
                  object:
                    type: string
                    enum:
                      - generation.job
                  created:
                    type: integer
                  completed:
                    anyOf:
                      - type: integer
                      - type: 'null'
                  status:
                    type: string
                    enum:
                      - queued
                      - submitted
                      - processing
                      - succeeded
                      - failed
                      - cancelled
                      - expired
                      - reconciliation_required
                  model:
                    type: string
                  poll_url:
                    type: string
                  reserved_amount:
                    type: string
                    pattern: ^[0-9]+\.[0-9]{4}$
                  currency:
                    type: string
                    enum:
                      - USD
                  data:
                    type: array
                    items:
                      type: object
                      additionalProperties: false
                      required:
                        - url
                      properties:
                        url:
                          type: string
                          format: uri
                  error:
                    anyOf:
                      - type: object
                        additionalProperties: false
                        required:
                          - code
                          - message
                        properties:
                          code:
                            type: string
                          message:
                            type: string
                      - type: 'null'
                  webhook_signing_secret:
                    type: string
                    pattern: ^whsec_[A-Za-z0-9_-]{43}$
        '401':
          description: Default Response
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required:
                  - error
                  - request_id
                properties:
                  error:
                    type: object
                    additionalProperties: false
                    required:
                      - message
                      - type
                      - param
                      - code
                    properties:
                      message:
                        type: string
                      type:
                        type: string
                      param:
                        type:
                          - 'null'
                          - string
                      code:
                        type: string
                  request_id:
                    type: string
        '403':
          description: Default Response
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required:
                  - error
                  - request_id
                properties:
                  error:
                    type: object
                    additionalProperties: false
                    required:
                      - message
                      - type
                      - param
                      - code
                    properties:
                      message:
                        type: string
                      type:
                        type: string
                      param:
                        type:
                          - 'null'
                          - string
                      code:
                        type: string
                  request_id:
                    type: string
        '404':
          description: Default Response
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required:
                  - error
                  - request_id
                properties:
                  error:
                    type: object
                    additionalProperties: false
                    required:
                      - message
                      - type
                      - param
                      - code
                    properties:
                      message:
                        type: string
                      type:
                        type: string
                      param:
                        type:
                          - 'null'
                          - string
                      code:
                        type: string
                  request_id:
                    type: string
      security:
        - apiKeyAuth: []
components:
  securitySchemes:
    apiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: nsfw_test_... or nsfw_live_...

````