Renderwolf API

Production REST/JSON rendering API: screenshots, PDFs, templated images, QR codes, video, site previews, signed URLs, async jobs, batches, destinations/deliveries and usage. Bearer API-key auth.

Operations 33

POST /v1/screenshot Render a screenshot #
POST /v1/qr Render a QR code #
POST /v1/pdf Render a PDF #
POST /v1/video Render a clip #
POST /v1/site-preview Render a scrolling website preview #
GET /v1/templates List templates #
POST /v1/templates Create a template #
GET /v1/templates/{id} Fetch a template #
PUT /v1/templates/{id} Replace a template #
DELETE /v1/templates/{id} Delete a template #
POST /v1/image/{id} Render a template #
POST /v1/sign Mint a signed render URL #
GET /v1/r/{exp}/{sig} Render from a signed URL #
GET /v1/capabilities What Renderwolf does, is building and does not offer #
POST /v1/jobs Submit a durable render job #
GET /v1/jobs List jobs #
GET /v1/jobs/{id} Poll a job #
DELETE /v1/jobs/{id} Cancel a job #
GET /v1/jobs/{id}/result Collect a job's result #
GET /v1/results/{exp}/{sig} Signed result download #
POST /v1/batches Submit up to 100 jobs together #
GET /v1/batches/{id} Poll a batch #
POST /v1/destinations Register a delivery destination #
GET /v1/destinations List destinations #
GET /v1/destinations/{id} Get a destination #
PATCH /v1/destinations/{id} Rename or enable a destination #
DELETE /v1/destinations/{id} Remove a destination #
POST /v1/destinations/{id}/test Test a destination now #
GET /v1/requests Request history #
GET /v1/deliveries List deliveries #
GET /v1/deliveries/{id} One delivery, with the body it posted #
POST /v1/deliveries/{id}/redeliver Send a delivery again #
GET /v1/usage Usage this period #

Documentation

Specifications

Other Resources

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/renderwolf-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

Free tier, no email required.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

ironfang-openapi.yaml Raw ↑
openapi: 3.1.0

# Written against the handlers in internal/httpapi, not from memory. When an
# endpoint changes, change this too - a spec that drifts is worse than none,
# because connectors are generated from it.
#
# Covers the customer-facing v1 API only. The dashboard endpoints
# (/v1/dashboard/*) are portal-internal and authenticated with Warden user
# tokens rather than API keys; the admin endpoints are operator tools. Neither
# belongs in a public contract.

info:
  title: Renderwolf API
  version: '1.0.0'
  summary: Screenshots, PDFs, dynamic images and video through one API.
  description: |
    Renderwolf renders web pages to images, PDFs and video. Give it a URL or a
    block of HTML and get the finished asset without running a browser.

    ## Free tier attribution

    Output from a free account carries a small Renderwolf badge in the bottom
    corner on screenshots, template images, PDFs, clips and site previews. Any
    paid plan removes it. It follows the account's plan rather than the request,
    so there is no field here that turns it on or off.

    ## Authentication

    Every request carries an API key as a bearer token:

    ```
    Authorization: Bearer rw_live_...
    ```

    Keys are created in the portal at https://portal.ironfang.uk. They are shown
    once at creation and stored only as a hash, so a lost key is replaced rather
    than recovered.

    ## Responses are bytes, not JSON

    `/v1/screenshot`, `/v1/pdf`, `/v1/image/{id}` and `/v1/video` return the
    rendered file directly. `/v1/site-preview` returns a multipart payload with
    the poster and MP4. Only errors and the metadata endpoints return JSON.

    ## Caching and billing

    Identical requests return a cached result, and **cache hits are free** - they
    do not count against quota. `X-Renderwolf-Cache: hit` tells you which you
    got. Set `no_cache: true` to force a fresh render, which is billable; the
    flag is deliberately excluded from the cache key, so two fresh requests do
    not hit each other's results.

    ## Quotas and rate limits

    Monthly quota depends on plan: free 250, hobby 5,000, pro 15,000, scale
    50,000 renders. For subscribers the period follows the billing anniversary
    rather than the calendar month.

    Two rate limits apply, both per minute:

    - **120 renders per account**, and
    - **60 renders per target host**, counted across all customers.

    The second protects the sites being rendered: one API call is a full page
    load, so a victim's exposure must not scale with our customer count. Both
    are checked after the cache and before charging, so a blocked call costs
    nothing.

    ## Bot protection

    Renderwolf does not attempt to circumvent bot protection, CAPTCHAs or
    paywalls. A challenge page renders as the challenge page. This is a
    deliberate limit, not a bug.
  contact:
    name: Ironfang
    email: hello@ironfang.uk
    url: https://ironfang.uk
  termsOfService: https://ironfang.uk/legal/terms
  license:
    name: Proprietary - use governed by the Ironfang terms of service
    url: https://ironfang.uk/legal/terms

# api.ironfang.uk carries every Ironfang product, so the product name is part
# of the path: /renderwolf/v1/usage, not /v1/usage. Product precedes version so
# each product versions on its own clock. The bare prefix is still served for
# clients written before the partition and is not going away, but new
# integrations should use the first server below.
servers:
  - url: https://api.ironfang.uk/renderwolf
    description: Production
  - url: https://api.ironfang.uk
    description: Production, unpartitioned - retained for existing clients

security:
  - apiKey: []

tags:
  - name: Render
    description: Turn a URL or HTML into an image, PDF or video.
  - name: Templates
    description: Stored HTML with `{{variable}}` placeholders, rendered on demand.
  - name: Signed URLs
    description: Shareable render URLs that need no API key.
  - name: Batches
    description: Up to 100 renders submitted, and refused, together.
  - name: Destinations
    description: Where a finished job goes - a signed webhook, or your own S3 bucket.
  - name: Account
    description: Usage against quota.

paths:
  /v1/screenshot:
    post:
      tags: [Render]
      summary: Render a screenshot
      description: |
        Supply exactly one of `url` or `html`. Returns the image bytes.
      operationId: createScreenshot
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScreenshotRequest'
            examples:
              url:
                summary: A page, full length
                value:
                  url: https://example.com
                  width: 1280
                  full_page: true
              element:
                summary: One element, on a dark-mode page
                value:
                  url: https://example.com/pricing
                  selector: '.pricing-table'
                  dark_mode: true
              html:
                summary: Your own markup, as JPEG
                value:
                  html: '<h1 style="font:600 48px sans-serif">Hello</h1>'
                  width: 1200
                  height: 630
                  format: jpeg
                  quality: 85
      responses:
        '200':
          $ref: '#/components/responses/Image'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/RenderFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/qr:
    post:
      tags: [Render]
      summary: Render a QR code
      description: |
        Styled, optionally logo-bearing QR codes, drawn natively and
        **verified before delivery**: every response is decoded and compared
        to `data` on the way out. A code that would not scan is a
        `422 unscannable` and the credit is returned - the guarantee is the
        feature.

        A logo forces the highest error-correction level and is composited
        over the center on a knockout tile (`logo_pad`). Send it inline as a
        `data:` URI (`logo`) or by URL (`logo_url`); remote logos face the
        same target guard and rate caps as screenshot URLs.

        QRs are deterministic, so identical requests hit the cache and cache
        hits are free. QR costs no credits on any plan (`CostQR` is zero) -
        still metered per kind, and never refused at a cap. Output never
        carries the free-tier badge - a mark inside a scannable symbol would
        corrupt it.

        To place a QR inside a stored template render, use the `qr` object
        on `POST /v1/image/{id}` instead - each entry becomes a
        `{{qr.<name>}}` variable holding a data URI.
      operationId: createQr
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QrRequest'
            examples:
              plain:
                summary: A link, defaults throughout
                value:
                  data: https://example.com/menu
              branded:
                summary: Rounded style with a centered logo
                value:
                  data: https://example.com
                  size: 600
                  dots: circle
                  dark: '#1b2a4a'
                  logo_url: https://example.com/logo.png
                  logo_size: 0.22
      responses:
        '200':
          $ref: '#/components/responses/Image'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/RenderFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/pdf:
    post:
      tags: [Render]
      summary: Render a PDF
      description: |
        Supply exactly one of `url` or `html`. Returns `application/pdf`.

        `header_html` and `footer_html` are Chromium print templates: they
        support the classes `date`, `title`, `url`, `pageNumber` and
        `totalPages`, which Chromium substitutes at print time.
      operationId: createPdf
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PdfRequest'
            examples:
              invoice:
                summary: An invoice with a page footer
                value:
                  html: '<h1>Invoice 1024</h1>'
                  print_background: true
                  footer_html: '<div style="font-size:9px;margin:0 auto"><span class="pageNumber"></span> of <span class="totalPages"></span></div>'
              page:
                summary: A page, landscape
                value:
                  url: https://example.com/report
                  landscape: true
                  print_background: true
      responses:
        '200':
          $ref: '#/components/responses/Pdf'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/RenderFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/video:
    post:
      tags: [Render]
      summary: Render a clip
      description: |
        A background, captions that appear on a schedule, an optional
        watermark and an optional audio bed, encoded to MP4.

        Rendered inside the request, so clips are capped at 60 seconds. Cost
        is one credit per second of vertical or landscape output, half that
        for `720p` and 0.6 for `square` - pixels are what the encoder spends.
        A clip that fails to render is refunded.

        Every asset URL is fetched by us through the same guard a screenshot
        target faces, and each is capped at 64 MB.
      operationId: createClip
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClipRequest'
            examples:
              captioned:
                summary: A captioned vertical clip on a solid colour
                value:
                  size: vertical
                  duration: 15
                  colour: '#101820'
                  captions:
                    - { text: 'Ship it on Friday', from: 0, to: 5 }
                    - { text: 'Find out on Monday', from: 5, to: 10 }
                    - { text: 'Or gate the deploy', from: 10, to: 15 }
              overBackground:
                summary: Over an image, with a logo and music
                value:
                  size: square
                  duration: 20
                  background: https://example.com/backdrop.jpg
                  watermark: https://example.com/logo.png
                  audio: https://example.com/bed.m4a
                  captions:
                    - { text: 'New in August', from: 1, to: 6 }
      responses:
        '200':
          $ref: '#/components/responses/Clip'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/RenderFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501':
          description: This deployment has no encoder configured.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/site-preview:
    post:
      tags: [Render]
      summary: Render a scrolling website preview
      description: |
        Loads and settles a website, preloads lazy content, then records a
        top-to-bottom browser motion at 30 frames per second. `per_page` moves
        one viewport at a time and pauses for 750ms between steps.
        `single_sweep` makes one continuous eased pass. Sticky and fixed
        elements behave as they do in the browser.

        The response is one `multipart/form-data` payload containing the first
        video frame as `poster.jpg` and the H.264 video as `preview.mp4`.
        Renderwolf calculates the duration from the page height and selected
        motion, up to a 60 second hard limit. Cost is one credit per output
        second, rounded up. Failed work is refunded and cached repeats are free.

        This endpoint is available on the product-partitioned Renderwolf URL
        only. It has no unprefixed `/v1/site-preview` compatibility route.
      operationId: createSitePreview
      servers:
        - url: https://api.ironfang.uk/renderwolf
          description: Production
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SitePreviewRequest'
            examples:
              showcase:
                summary: A showcase card preview
                value:
                  url: https://example.com
                  width: 672
                  height: 494
                  motion: per_page
      responses:
        '200':
          $ref: '#/components/responses/SitePreview'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/RenderFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501':
          description: This deployment does not have browser video capture configured.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/templates:
    get:
      tags: [Templates]
      summary: List templates
      operationId: listTemplates
      responses:
        '200':
          description: |
            Your templates, wrapped in a `templates` property - not a bare
            array. Clients that iterate must read `templates`.
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items: { $ref: '#/components/schemas/Template' }
              example:
                templates:
                  - id: 01a01c7c-682e-7b2a-95cd-ffb2f2a4d9be
                    name: og-card
                    width: 1200
                    height: 630
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Templates]
      summary: Create a template
      description: |
        Store HTML containing `{{placeholders}}`. Render it with
        `POST /v1/image/{id}`, supplying values for the placeholders.
      operationId: createTemplate
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TemplateInput' }
            examples:
              og:
                summary: An Open Graph card
                value:
                  name: og-card
                  html: '<div style="font:700 64px sans-serif;padding:80px">{{title}}</div>'
                  width: 1200
                  height: 630
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Template' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/templates/{id}:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
    get:
      tags: [Templates]
      summary: Fetch a template
      operationId: getTemplate
      responses:
        '200':
          description: The template.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Template' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Templates]
      summary: Replace a template
      operationId: updateTemplate
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TemplateInput' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Template' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Templates]
      summary: Delete a template
      operationId: deleteTemplate
      responses:
        '204':
          description: Deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/image/{id}:
    parameters:
      - $ref: '#/components/parameters/TemplateId'
    post:
      tags: [Templates]
      summary: Render a template
      description: |
        Substitutes `vars` into the template's `{{placeholders}}` and renders it
        at the template's own width and height. Returns the image bytes.
      operationId: renderTemplate
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RenderTemplateRequest' }
            examples:
              card:
                value:
                  vars:
                    title: Shipping UUIDv7 everywhere
                  format: png
      responses:
        '200':
          $ref: '#/components/responses/Image'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/RenderFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/sign:
    post:
      tags: [Signed URLs]
      summary: Mint a signed render URL
      description: |
        Returns a URL that renders on GET without an API key - safe to put in an
        `<img>` tag or an email. The URL is bound to your account, so renders
        through it meter against your quota.

        Requires signing to be configured on the deployment; returns `501`
        otherwise.
      operationId: createSignedUrl
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SignRequest' }
            examples:
              screenshot:
                value:
                  kind: screenshot
                  url: https://example.com
                  width: 1280
                  ttl_hours: 24
              template:
                value:
                  kind: image
                  template: 0193f0c4-1f4e-7a91-9c3e-2b6f5d8a1e77
                  vars: { title: Hello }
      responses:
        '200':
          description: The signed URL.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SignedUrl' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '501':
          description: Signing is not configured on this deployment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/r/{exp}/{sig}:
    get:
      tags: [Signed URLs]
      summary: Render from a signed URL
      description: |
        The endpoint a signed URL points at. Takes no API key - the signature is
        the credential - so it can be embedded directly in HTML or email. Call
        `POST /v1/sign` to mint one rather than constructing it by hand.
      operationId: renderSignedUrl
      security: []
      parameters:
        - name: exp
          in: path
          required: true
          description: Expiry stamp, part of the signed payload. `0` never expires.
          schema: { type: string }
        - name: sig
          in: path
          required: true
          description: The signature.
          schema: { type: string }
      responses:
        '200':
          $ref: '#/components/responses/Image'
        '403':
          description: Signature invalid or expired.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Signing is not configured on this deployment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/capabilities:
    get:
      tags: [Account]
      security: []
      summary: What Renderwolf does, is building and does not offer
      description: |
        A dated statement of capabilities with a status of `live`, `planned`
        or `not_offered`. Public and cacheable; the comparison pages are
        checked against the same file, so nothing is advertised before it is
        live.
      operationId: getCapabilities
      responses:
        '200':
          description: The capability catalogue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  product: { type: string }
                  updated: { type: string, format: date }
                  free_credits_per_month: { type: integer }
                  capabilities:
                    type: array
                    items:
                      type: object
                      properties:
                        key: { type: string }
                        name: { type: string }
                        status: { type: string, enum: [live, planned, not_offered] }
                        since: { type: string, format: date }
  /v1/jobs:
    post:
      tags: [Jobs]
      summary: Submit a durable render job
      description: |
        The same renders as the synchronous endpoints - `kind` is one of
        `screenshot`, `pdf`, `qr`, `image`, `clip` or `site_preview` and
        `request` is that endpoint's body (an `image` job names its
        `template` inside `request`) - accepted with `202` and run by a
        worker. Poll the job, or cancel it, then collect the result while it
        is hosted: 24 hours, private, through a signed link valid for 15
        minutes. A process restart after the `202` never loses the job.

        Send `Idempotency-Key` on every submission. The same key with the same
        request returns the existing job with `200`; the same key with a
        different request is a `409 idempotency_conflict`. `external_id` is
        your own reference, up to 120 characters.

        Credits: the job's maximum cost is reserved when it is accepted and
        settled when it finishes - a site preview reserves the 60-second
        ceiling and releases the difference. Failed work is refunded. A
        queued job cancels for free; a running one is charged only if it
        produced a usable output.
      operationId: submitJob
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, request]
              properties:
                kind: { type: string, enum: [screenshot, pdf, qr, image, clip, site_preview] }
                request: { type: object, description: The synchronous endpoint's request body for that kind. }
                external_id: { type: string, maxLength: 120 }
                delivery: { $ref: '#/components/schemas/JobDelivery' }
      responses:
        '202':
          description: The job was accepted; the body is the job resource.
        '200':
          description: The same Idempotency-Key and request were seen before; this is that job.
        '400': { description: Invalid submission. }
        '403': { description: The key carries no `renderwolf:render` scope, or is not account-backed. }
        '404': { description: The template named by an image job does not exist. }
        '409': { description: '`idempotency_conflict`: the key was used with a different request.' }
        '429': { description: Monthly credits exhausted. }
        '501': { description: This deployment cannot render clips or site previews. }
    get:
      tags: [Jobs]
      summary: List jobs
      description: Newest first. `status` filters; `limit` up to 100; pass `next_cursor` back to continue.
      operationId: listJobs
      parameters:
        - { name: status, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, maximum: 100 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        '200': { description: Jobs and `next_cursor`. }
  /v1/jobs/{id}:
    get:
      tags: [Jobs]
      summary: Poll a job
      description: |
        `status` is `queued`, `running`, `cancellation_requested`,
        `succeeded`, `failed` or `cancelled`. A succeeded job carries
        `result` with `content_type`, `bytes`, `sha256`, `expires_at` and,
        while hosted, a signed `url` valid for 15 minutes. A failed job
        carries `error.code` and `error.message`. The submitted request is
        never returned; `request_summary` is the redacted shape.
      operationId: getJob
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: The job resource. }
        '404': { description: No such job on this account. }
    delete:
      tags: [Jobs]
      summary: Cancel a job
      description: A queued job is cancelled and refunded at once; a running one stops at its next safe point.
      operationId: cancelJob
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: The job, cancelled or with cancellation requested. }
        '404': { description: No such job on this account. }
        '409': { description: '`job_finished`: nothing left to cancel.' }
  /v1/jobs/{id}/result:
    get:
      tags: [Jobs]
      summary: Collect a job's result
      description: |
        Redirects (`302`) to a signed download link valid for 15 minutes; the
        link needs no API key. The hosted result is kept for 24 hours after
        success, privately, on Ironfang's side - not permanent hosting.
      operationId: getJobResult
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '302': { description: Redirect to the signed download link. }
        '404': { description: No such job on this account. }
        '409': { description: '`result_not_ready`: the job has not succeeded.' }
        '410': { description: '`result_expired`: the hosted result is gone; run the job again.' }
  /v1/results/{exp}/{sig}:
    get:
      tags: [Jobs]
      security: []
      summary: Signed result download
      description: The link a job's `result.url` points at. The signature is the authorisation; it expires after 15 minutes.
      operationId: getSignedResult
      parameters:
        - { name: exp, in: path, required: true, schema: { type: string } }
        - { name: sig, in: path, required: true, schema: { type: string } }
        - { name: job, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: The result bytes with their recorded content type. }
        '403': { description: Bad or expired signature. }
        '410': { description: The hosted result has expired. }
  /v1/batches:
    post:
      tags: [Batches]
      summary: Submit up to 100 jobs together
      description: |
        One `default` request plus `items` that override parts of it, merged
        one level deep: an item's field wins, everything else comes from the
        default. The default is written for the batch's `kind`, so it applies
        only to items of that kind; an item that names a different kind stands
        on its own request, and any item may carry its own `delivery`. Every item is validated as a single job would be,
        and an unknown field is refused rather than ignored, because a typo
        that is silently dropped is a credit spent on the wrong render.

        The batch is accepted or refused whole. Every item is validated before
        any is stored, and the credits for all of them are reserved in one
        transaction, so a batch that would exceed your monthly credits is a
        `429` that charged nothing and left no jobs behind. The error names
        the item that was wrong.

        Items are ordinary jobs: poll them individually, or poll the batch for
        the aggregate. There is no ZIP of results - configure a storage
        destination if you want the output collected in one place.
      operationId: submitBatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                kind: { type: string, enum: [screenshot, pdf, qr, image, clip, site_preview] }
                default: { type: object, description: The request every item starts from. }
                delivery: { $ref: '#/components/schemas/JobDelivery' }
                external_id: { type: string, maxLength: 120 }
                items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    properties:
                      kind: { type: string }
                      request: { type: object }
                      external_id: { type: string, maxLength: 120 }
                      delivery: { $ref: '#/components/schemas/JobDelivery' }
      responses:
        '202':
          description: The batch was accepted; the body carries the batch and one job per item.
        '400': { description: 'Invalid submission, or `batch_too_large`. The message names the item.' }
        '403': { description: The key carries no `renderwolf:render` scope, or is not account-backed. }
        '429': { description: '`quota_exhausted`: the whole batch was refused and nothing was charged.' }
  /v1/batches/{id}:
    get:
      tags: [Batches]
      summary: Poll a batch
      description: |
        `counts` totals the items by state and `done` is true once none of
        them can change. `jobs` is the full job resource for every item.
      operationId: getBatch
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: The batch, its counts and its jobs. }
        '404': { description: No such batch on this account. }
  /v1/destinations:
    post:
      tags: [Destinations]
      summary: Register a delivery destination
      description: |
        A destination is created once and named by id from every job, so a
        signing secret or a set of S3 keys is sent to us on one request and
        never appears in a job body again.

        A `webhook` destination needs a public `https` URL. The response
        carries `signing_secret` **once**: we keep only an encrypted copy,
        so there is no endpoint that can show it to you again.

        An `s3` destination needs `bucket`, `region`, `access_key` and
        `secret_key`; `endpoint` (for a non-AWS provider), `path_style` and
        `prefix` are optional. Scope the credentials to the prefix you give
        us. They are never returned after creation.

        Needs the `renderwolf:destinations` scope, which maps to the
        `destinations.manage` permission.
      operationId: createDestination
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type: { type: string, enum: [webhook, s3] }
                name: { type: string, maxLength: 120 }
                url: { type: string, format: uri, description: 'webhook: a public https URL.' }
                endpoint: { type: string, format: uri }
                region: { type: string }
                bucket: { type: string }
                prefix: { type: string, maxLength: 256 }
                path_style: { type: boolean }
                access_key: { type: string 

# --- truncated at 32 KB (67 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/ironfang/refs/heads/main/openapi/ironfang-openapi.yaml