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: '

Hello

' 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.}}` 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: '

Invoice 1024

' print_background: true footer_html: '
of
' 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: '
{{title}}
' 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 `` 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 } secret_key: { type: string } session_token: { type: string } responses: '201': { description: 'The destination. A webhook also carries `signing_secret`, shown once.' } '400': { description: '`bad_destination`: not a public https URL, or missing bucket credentials.' } '403': { description: The key carries no `renderwolf:destinations` scope. } '501': { description: This deployment has no credential vault configured. } get: tags: [Destinations] summary: List destinations description: Live destinations, newest first. Secrets and credentials are never included. operationId: listDestinations responses: '200': { description: The account's destinations. } /v1/destinations/{id}: get: tags: [Destinations] summary: Get a destination operationId: getDestination parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': { description: The destination. } '404': { description: No such destination on this account. } patch: tags: [Destinations] summary: Rename or enable a destination description: | `name` and `enabled` only. An address or a credential is not edited in place - create a new destination and point your jobs at it, so a delivery's history always says where it actually went. Re-enabling clears the failure count. operationId: updateDestination parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object properties: name: { type: string, maxLength: 120 } enabled: { type: boolean } responses: '200': { description: The destination. } '404': { description: No such destination on this account. } delete: tags: [Destinations] summary: Remove a destination description: | It stops receiving anything and leaves the list. Its past deliveries stay, so history still reads true. operationId: deleteDestination parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '204': { description: Removed. } '404': { description: No such destination on this account. } /v1/destinations/{id}/test: post: tags: [Destinations] summary: Test a destination now description: | A webhook receives a signed `renderwolf.destination.test` event; a bucket has a uniquely named object written to it. Always `200`: the body's `ok` says whether the destination answered, and `error` says what went wrong if it did not. operationId: testDestination parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': { description: '`ok` and, on failure, `error`.' } '404': { description: No such destination on this account. } /v1/requests: get: tags: [Account] summary: Request history description: | One row per authenticated render in the last seven days, newest first, synchronous calls and jobs alike: kind, outcome, safe error code, cache hit or miss, credits charged and refunded, response size and type, duration, the target's origin and path, the names of the option fields supplied, and the request id you can quote to support. It is diagnostic, not replay. Nothing here can rebuild the request: header and cookie values, HTML, template variables, URL query strings and signatures are never recorded. Retention is seven days on every plan. `id` matches the request id, a job id or your own `X-Request-ID`, which is how you arrive here from an error message. operationId: listRequests parameters: - { name: since, in: query, schema: { type: string, format: date-time } } - { name: until, in: query, schema: { type: string, format: date-time } } - { name: outcome, in: query, schema: { type: string, enum: [ok, error, cancelled] } } - { name: kind, in: query, schema: { type: string, enum: [screenshot, pdf, qr, image, clip, site_preview] } } - { name: cache, in: query, schema: { type: string, enum: [hit, miss] } } - { name: key, in: query, description: API key id, schema: { type: string } } - { name: error, in: query, description: Safe error code, schema: { type: string } } - { name: id, in: query, description: Request id, job id or your X-Request-ID, schema: { type: string } } - { name: limit, in: query, schema: { type: integer, maximum: 200, default: 50 } } - { name: offset, in: query, schema: { type: integer, default: 0 } } responses: '200': { description: "`requests` and `retention_days`." } '400': { $ref: '#/components/responses/BadRequest' } /v1/deliveries: get: tags: [Destinations] summary: List deliveries description: | Newest first, optionally for one `destination`. A delivery is `pending`, `delivering`, `delivered` or `failed`; `failed` means it used up its attempts. Delivery state never affects the job's own outcome. operationId: listDeliveries parameters: - { name: destination, in: query, schema: { type: string } } - { name: limit, in: query, schema: { type: integer, maximum: 100 } } - { name: cursor, in: query, schema: { type: string } } responses: '200': { description: Deliveries and `next_cursor`. } /v1/deliveries/{id}: get: tags: [Destinations] summary: One delivery, with the body it posted description: | The list omits payloads because they are the largest part of a delivery and are wanted one at a time, when something has gone wrong. This returns the body exactly as it went on the wire, plus `payload_timestamp` and `payload_signature` - the two values the signature is computed over - so you can reproduce the HMAC check against your own secret and see where it diverges. `response_body` is what your endpoint sent back, bounded to 4 KB. Bodies are kept for `payload_retention_days` (7) after the attempt and then blanked; the delivery row itself stays, so the history of what was sent and how it went is not lost with it. operationId: getDelivery parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': { description: 'The delivery. `payload` is absent once purged.' } '404': { description: No such delivery on this account. } /v1/deliveries/{id}/redeliver: post: tags: [Destinations] summary: Send a delivery again description: | Puts a delivery back on the queue with a fresh attempt ladder, for after you have fixed whatever was wrong. The webhook body is rebuilt at send time, so the result link in it is good for 15 minutes from then rather than from the original attempt. operationId: redeliverDelivery parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': { description: The delivery, queued again. } '404': { description: No such delivery on this account. } /v1/usage: get: tags: [Account] summary: Usage this period description: | For subscribers the period runs from the billing anniversary, not the first of the month, so it lines up with invoices. operationId: getUsage responses: '200': description: Current period usage. content: application/json: schema: { $ref: '#/components/schemas/Usage' } '401': { $ref: '#/components/responses/Unauthorized' } components: securitySchemes: apiKey: type: http scheme: bearer description: | An API key from the portal, sent as `Authorization: Bearer rw_live_...`. parameters: TemplateId: name: id in: path required: true description: Template id (UUIDv7). schema: type: string format: uuid responses: Image: description: | The rendered image. `png` unless `format: jpeg` was requested. headers: X-Renderwolf-Cache: description: '`hit` when served from cache, in which case it was free.' schema: { type: string, enum: [hit] } X-Renderwolf-Credits: description: | What this call cost. `0` on a cache hit, because a cache hit is free. Read it to log spend per request without polling `/v1/usage`. schema: { type: integer } X-Renderwolf-Render-Ms: description: Render time in milliseconds, excluding any requested delay. schema: { type: integer } X-Renderwolf-Delay-Ms: description: The `delay_ms` actually waited, when one was requested. schema: { type: integer } X-Renderwolf-Captured-At: description: | RFC 3339 timestamp of a fresh capture. Present only on `no_cache` renders, where the response is also `no-store` - the pairing is what makes a capture usable as evidence. schema: { type: string, format: date-time } content: image/png: schema: { type: string, format: binary } image/jpeg: schema: { type: string, format: binary } Clip: description: The rendered clip. headers: X-Renderwolf-Cache: schema: { type: string, enum: [hit] } X-Renderwolf-Render-Ms: schema: { type: integer } X-Renderwolf-Credits: description: | What this clip cost. `0` on a cache hit, because a cache hit is free. schema: { type: integer } content: video/mp4: schema: { type: string, format: binary } SitePreview: description: The poster image and scrolling MP4 returned as two file parts. headers: X-Renderwolf-Cache: schema: { type: string, enum: [hit] } X-Renderwolf-Render-Ms: schema: { type: integer } X-Renderwolf-Credits: description: | What this preview cost. `0` on a cache hit, because a cache hit is free. The poster is included in the video cost. schema: { type: integer } X-Renderwolf-Output-Seconds: description: Calculated video length in seconds. Present on fresh renders. schema: { type: number } content: multipart/form-data: schema: type: object required: [poster, video] properties: poster: type: string format: binary description: JPEG first frame, suitable for a video poster. video: type: string format: binary description: H.264 MP4 with fast-start metadata. Pdf: description: The rendered PDF. headers: X-Renderwolf-Cache: schema: { type: string, enum: [hit] } X-Renderwolf-Credits: description: | What this call cost. `0` on a cache hit, because a cache hit is free. Read it to log spend per request without polling `/v1/usage`. schema: { type: integer } X-Renderwolf-Render-Ms: schema: { type: integer } content: application/pdf: schema: { type: string, format: binary } BadRequest: description: Malformed body, or neither/both of `url` and `html`. content: application/json: schema: { $ref: '#/components/schemas/Error' } Unauthorized: description: Missing, malformed, revoked or unknown API key. content: application/json: schema: { $ref: '#/components/schemas/Error' } NotFound: description: No such template, or not yours. content: application/json: schema: { $ref: '#/components/schemas/Error' } RateLimited: description: | Three distinct conditions share this status, separated by `error.code`: - `rate_limited` - your account exceeded 120 renders/minute - `target_rate_limited` - the site being rendered is being hit too hard across all customers (60/minute per host) - `quota_exhausted` - the plan's monthly allowance is spent The first two clear within the minute, so retry with backoff. The third does not: upgrade in the portal or wait for the period to roll over. Branch on the code rather than the status. content: application/json: schema: { $ref: '#/components/schemas/Error' } RenderFailed: description: | The page did not render: the host did not resolve, it never finished loading, it timed out, or it was blocked. Try `delay_ms`, and check the target loads in a normal browser. content: application/json: schema: { $ref: '#/components/schemas/Error' } schemas: Error: type: object required: [error] properties: error: type: object required: [code, message] properties: code: type: string description: Stable, safe to branch on. enum: - bad_request - unauthorized - invalid_api_key - auth_failed - forbidden - email_unverified - not_found - quota_exhausted - rate_limited - target_rate_limited - render_failed - bad_signature - signing_disabled message: type: string description: Human-readable. May change; do not match on it. example: error: code: target_rate_limited message: too many renders for that host, try again shortly Cookie: type: object required: [name, domain] properties: name: type: string value: type: string domain: type: string description: | Required. Chromium silently drops a domainless cookie set before navigation, so the render would quietly come back logged out. path: type: string default: / Clip: type: object description: A rectangle in CSS pixels from the top left of the page. required: [width, height] properties: x: type: number default: 0 y: type: number default: 0 width: type: number maximum: 4096 height: type: number maximum: 4096 Margin: type: object description: | Page margins in inches. Omitted sides keep Chromium's default rather than becoming zero - a PDF printed hard to the paper edge is rarely what leaving a field out was meant to mean. properties: top: type: number right: type: number bottom: type: number left: type: number RenderCommon: type: object properties: no_cache: type: boolean default: false description: | Force a fresh render instead of serving a cached one. Always billable. Excluded from the cache key, so repeated fresh requests do not collide. block_ads: type: boolean default: false description: | Drop requests to known ad and tracker networks before they load. Stopped as network requests rather than hidden afterwards, so the page never spends time fetching them. block_cookie_banners: type: boolean default: false description: | Hide common consent banners, and undo the scroll lock they set - without which a full-page capture is one viewport tall. Hidden, not accepted. Clicking "accept" would be a decision made on the site owner's behalf and recorded as theirs. hide_selectors: type: array items: type: string description: | CSS selectors to hide before capturing. Applied after load, so elements injected by script are covered. headers: type: object additionalProperties: type: string description: Extra HTTP headers sent with every request for the page. cookies: type: array items: $ref: '#/components/schemas/Cookie' description: | Cookies to set before navigating. Set beforehand because a session cookie that arrives after load has missed the request it was meant to authenticate. authorization: type: string description: | Sets the `Authorization` header - the common case, spelled once. An explicit `Authorization` in `headers` wins over this. user_agent: type: string description: | Overrides the user agent, including any set by `device`. wait_until: type: string enum: [load, domcontentloaded, networkidle] default: load description: | When the page counts as ready. `networkidle` waits for traffic to stop and is bounded by the render timeout, because a page with polling never truly idles. wait_for_selector: type: string description: | Wait for this element before capturing. Fails the render if it never appears, rather than returning a half-drawn page. timeout_ms: type: integer description: | Per-render timeout. Clamped to the service maximum - it can lower the ceiling but not raise it. device_scale_factor: type: number minimum: 1 maximum: 3 description: | Pixel density. 2 is retina: the same CSS size at twice the pixels. QrRequest: type: object required: [data] properties: data: type: string maxLength: 1024 description: The payload - a URL, WiFi string, or any text up to 1KB. size: type: integer default: 512 minimum: 64 maximum: 2048 description: Output side in pixels. Output is always square PNG. ecc: type: string enum: [L, M, Q, H] default: M description: | Error-correction level. Forced to `H` when a logo is present - the logo destroys the modules it covers, and that budget has to come from somewhere. dark: type: string default: '#000000' description: Module color, `#rgb` or `#rrggbb`. Must actually be darker than `light`. light: type: string default: '#ffffff' description: | Background color, or `transparent`. Transparent codes are verified against a white ground - place them on light surfaces. dots: type: string enum: [square, circle] default: square description: | Module style. `circle` draws separated dots. Finder patterns always render solid - styled finders are how QR generators produce codes that do not scan. eyes: type: string enum: [square, rounded] description: Finder-pattern style. Defaults to match `dots`. margin: type: integer default: 4 minimum: 0 maximum: 8 description: Quiet zone, in modules. invert: type: boolean default: false description: | Light modules on a dark ground. Explicit because it narrows the audience: modern phone cameras read inverted codes, many embedded and in-app scanners do not - use it for screens, not print or payments. With the flag set, `dark` (the module color) must be lighter than `light` (the ground). Verified against the negative, so the structural guarantee holds. logo: type: string description: | Center mark as a base64 `data:` URI, png or jpeg, up to 1MB decoded. Mutually exclusive with `logo_url`. logo_url: type: string format: uri description: Center mark by URL. SSRF-guarded and rate-capped like a render target. logo_size: type: number default: 0.22 minimum: 0.12 maximum: 0.3 description: Logo tile as a fraction of the symbol width. logo_pad: type: boolean default: true description: Knockout tile behind the logo so it sits on clean ground. JobDelivery: type: object description: | Where this job goes when it finishes, by destination id. Register the destination first; credentials are never sent here. A webhook fires on success, failure and cancellation. Storage only carries a result, so it fires on success alone - and a storage delivery that gives up raises `render.delivery.failed` on the webhook destination, which is how you find out that a bucket stopped accepting uploads. properties: webhook_destination: type: string description: Id of a `webhook` destination. storage_destination: type: string description: Id of an `s3` destination. storage_key: type: string maxLength: 700 description: | The object key, relative to the destination's prefix. Fixed text plus `{job_id}`, `{external_id}` and `{date}` (`YYYY/MM/DD`) - no other field, and nothing evaluated. Segments may hold letters, digits, dot, dash and underscore; anything that would climb out of the prefix is refused when the job is submitted, not quietly rewritten. Defaults to `renderwolf/{date}/{job_id}`. example: captures/{external_id}/{job_id}.png ScreenshotRequest: allOf: - $ref: '#/components/schemas/RenderCommon' - type: object properties: url: type: string format: uri description: Page to capture. Mutually exclusive with `html`. html: type: string description: Markup to render. Mutually exclusive with `url`. width: type: integer default: 1280 maximum: 4096 description: Viewport width in pixels. height: type: integer default: 800 maximum: 4096 description: Viewport height. Ignored when `full_page` is set. full_page: type: boolean default: false description: Capture the whole scrollable page. selector: type: string description: | CSS selector of a single element to capture instead of the viewport. clip: $ref: '#/components/schemas/Clip' omit_background: type: boolean default: false description: | Transparent background instead of the page's own. Ignored for `jpeg`, which has no alpha channel. full_page_max_height: type: integer default: 20000 maximum: 20000 description: | Cap a `full_page` capture at this many pixels. An infinite-scroll page has no natural end, so 20000 applies when none is given, and is also the most that can be asked for. dark_mode: type: boolean default: false description: 'Render with `prefers-color-scheme: dark`.' format: type: string enum: [png, jpeg, webp] default: png description: | PNG is lossless and the default. WebP is typically a fraction of the size for the same picture, which is what matters when the output is served to browsers or social crawlers. quality: type: integer minimum: 1 maximum: 100 default: 85 description: Quality for the lossy formats, `jpeg` and `webp`. Ignored for PNG. device: type: string enum: [desktop, tablet, mobile] description: | Capture as a phone or tablet rather than a desktop window that happens to be narrow. Sets the viewport, the device pixel ratio, touch, and a matching mobile user agent together - width alone gets media queries right and everything else wrong. An explicit `width` or `height` still wins, so a preset can be used for the density and user agent while overriding the size. delay_ms: type: integer minimum: 0 maximum: 10000 description: | Extra settle time after load. Renderwolf watches the page and captures when it stops changing, so this is rarely needed - use it for content that arrives on a timer, which quiescence cannot anticipate. Capped at ten seconds. SitePreviewRequest: allOf: - $ref: '#/components/schemas/RenderCommon' - type: object required: [url] properties: url: type: string format: uri description: Public HTTP or HTTPS page to record. width: type: integer default: 672 minimum: 320 maximum: 1280 multipleOf: 2 description: Output and browser viewport width in pixels. height: type: integer default: 494 minimum: 240 maximum: 1200 multipleOf: 2 description: | Output and browser viewport height in pixels. Width multiplied by height cannot exceed 1,200,000 pixels. motion: type: string enum: [per_page, single_sweep] default: per_page description: | `per_page` moves one viewport at a time with eased transitions and a 750ms reading pause. `single_sweep` makes one continuous eased pass from top to bottom. dark_mode: type: boolean default: false description: 'Record with `prefers-color-scheme: dark`.' device: type: string enum: [desktop, tablet, mobile] description: | Use a full device preset. Explicit width and height still win. delay_ms: type: integer minimum: 0 maximum: 10000 description: Extra settle time after page load and before lazy-content preloading. Capped at ten seconds. no_cache: type: boolean default: false description: Force a fresh recording instead of using a cached result. ClipRequest: type: object description: | Everything is optional. With no background a solid `colour` is used, which is what most captioned clips want. properties: size: type: string enum: [vertical, square, landscape, 720p] default: vertical description: | vertical 1080x1920, square 1080x1080, landscape 1920x1080, 720p 1280x720. duration: type: number default: 15 minimum: 1 maximum: 60 description: Seconds of output. colour: type: string pattern: '^#[0-9a-fA-F]{6}$' default: '#101820' description: Background colour, used when no background asset is given. background: type: string format: uri description: An image or video to fill the canvas, cropped to fit. watermark: type: string format: uri description: A PNG placed in the bottom right corner. audio: type: string format: uri description: An audio track, trimmed to the clip's length. font_size: type: integer minimum: 12 maximum: 200 description: Caption size in pixels; defaults to a fifteenth of the width. captions: type: array maxItems: 12 items: type: object required: [text, to] properties: text: { type: string, maxLength: 280 } from: { type: number, description: Seconds from the start. } to: { type: number } PdfRequest: allOf: - $ref: '#/components/schemas/RenderCommon' - type: object properties: url: type: string format: uri description: Page to print. Mutually exclusive with `html`. html: type: string description: Markup to print. Mutually exclusive with `url`. landscape: type: boolean default: false paper_format: type: string enum: [a3, a4, a5, letter, legal, tabloid] description: | Named page size. Omit for Chromium's default, which is Letter. Sizes are portrait; `landscape` rotates them. margin: $ref: '#/components/schemas/Margin' print_background: type: boolean default: false description: | Include background colours and images. Off by default, matching a browser's print dialog. header_html: type: string description: Chromium print header template. footer_html: type: string description: Chromium print footer template. scale: type: number description: Print scale. Omit or `0` for 1.0. TemplateInput: type: object required: [name, html] properties: name: { type: string } html: type: string description: HTML with `{{placeholder}}` markers. width: { type: integer, default: 1280 } height: { type: integer, default: 800 } Template: type: object properties: id: { type: string, format: uuid } name: { type: string } html: { type: string } width: { type: integer } height: { type: integer } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } RenderTemplateRequest: allOf: - $ref: '#/components/schemas/RenderCommon' - type: object properties: vars: type: object additionalProperties: { type: string } description: Values for the template's placeholders. format: type: string enum: [png, jpeg, webp] default: png SignRequest: type: object required: [kind] properties: kind: type: string enum: [screenshot, image] description: | `screenshot` signs a URL capture and requires `url`; `image` signs a template render and requires `template`. url: type: string format: uri description: Required when `kind` is `screenshot`. template: type: string format: uuid description: Required when `kind` is `image`. vars: type: object additionalProperties: { type: string } width: { type: integer, maximum: 4096 } height: { type: integer, maximum: 4096 } full_page: { type: boolean, default: false } ttl_hours: type: integer default: 0 description: Hours until the URL expires. `0` never expires. SignedUrl: type: object properties: url: type: string format: uri description: Absolute URL, ready to embed. path: type: string description: Path only, for serving behind your own domain. Usage: type: object properties: period: type: string description: | The period these numbers cover. Follows the billing anniversary for subscribers, the calendar month otherwise. credits: type: integer description: Credits spent so far this period. Cache hits are free and not counted. renders: type: integer deprecated: true description: The same number under the name the API shipped with. limit: type: integer description: Credits included in the plan for this period, grants included. period_start: type: string format: date period_end: type: string format: date description: The period as [period_start, period_end) in UTC dates. by_kind: type: object description: | Where this period's credits went, keyed by render kind (`screenshot`, `pdf`, `image`, `qr`, `clip`). Kinds with no spend are absent. additionalProperties: type: object properties: credits: { type: integer } count: { type: integer } unattributed: type: integer description: | Credits from before per-kind metering existed. The breakdown plus this always equals `credits`. daily: type: array description: The last 30 days of spend, oldest first, zero days included. items: type: object properties: day: { type: string, format: date } credits: { type: integer } by_kind: type: object additionalProperties: { type: integer } example: period: '2026-08' credits: 412 renders: 412 limit: 5000 period_start: '2026-08-01' period_end: '2026-09-01' by_kind: screenshot: { credits: 231, count: 231 } pdf: { credits: 118, count: 59 } qr: { credits: 63, count: 63 } daily: - day: '2026-08-25' credits: 41 by_kind: { screenshot: 30, pdf: 8, qr: 3 }