Every API here is available over the APIs.io API and to AI agents over MCP.
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