Quickstart
All endpoints use https://api.ironfang.com/render as the base URL. The product name appears in the path because api.ironfang.com hosts all Ironfang APIs. The legacy unprefixed https://api.ironfang.com/v1/ path remains supported for endpoints that previously shipped there. New endpoints use the product base URL. Create an API key in the portal, then render your first screenshot:
curl -X POST https://api.ironfang.com/render/v1/screenshot \
-H "Authorization: Bearer if_live_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "width": 1280}' \
--output shot.pngSuccessful render requests return the binary asset directly as image/png, image/jpeg or application/pdf. Clips return video/mp4. Site previews return a multipart response containing a JPEG poster and MP4 video. Other successful endpoints and all error responses return JSON.
Client libraries
Official clients wrap the same API - typed requests, the webhook verifier, and result downloads included:
npm install @ironfang/render # TypeScript, Node 20+
pip install ironfang-render # PythonThere is also a standalone CLI - single binaries for Linux, macOS and Windows with checksums, from github.com/ironfang-ltd/cli.
Postman
Every endpoint is in a public Postman collection generated from the OpenAPI contract, with a Getting started folder whose first request needs no key.
Authentication
Send your API key as a bearer token with every request: Authorization: Bearer if_live_.... Create and revoke keys in the portal. The secret is displayed once when the key is created and is stored as a hash. A missing or unrecognised key returns 401 invalid_api_key.
Scopes
An Ironfang API key can provide access to multiple products. Select its scopes when you create it. Scopes cannot be expanded later, so create a new key when broader access is required. A request outside the key's scopes returns 403 insufficient_scope and identifies the missing scope.
render:renderScreenshots, PDFs, images, clips and site previewsrender:signCreate signed render URLsrender:templates:readRead stored templatesrender:templates:writeCreate, edit and delete templatesrender:usage:readRead this month's usage
Keys created before scopes were introduced have no assigned scopes and retain full access for backward compatibility. New keys are always created with explicit scopes.
POST /v1/screenshot
Capture a URL or raw HTML with a warm Chromium instance. Each screenshot counts once against render.screenshot.
| Field | Type | Notes |
|---|---|---|
url | string | Page to capture. Provide url or html, not both. Private-network targets are blocked. |
html | string | Raw HTML to render instead of a URL. |
width | int | Viewport width. Default 1280. |
height | int | Viewport height. Default 800. |
device | string | desktop, tablet or mobile. Each preset applies a viewport, pixel density and user agent. See Devices. |
full_page | bool | Capture the full scroll height instead of the viewport. |
selector | string | CSS selector for capturing a single element. |
dark_mode | bool | Emulate prefers-color-scheme: dark. |
format | string | png (default), jpeg or webp. WebP can produce a smaller file than PNG for the same capture. |
quality | int | JPEG and WebP quality from 1 to 100. Default 85. |
delay_ms | int | Additional wait time after load for late-rendering content. |
no_cache | bool | Force a new capture, ignoring any cached render. A fresh render is counted. See Caching. |
clip | object | { x, y, width, height } in CSS pixels from the top left. This field is ignored when selector is set because the selector defines the capture region. |
omit_background | bool | Use a transparent background instead of the page background. This field is ignored for jpeg, which has no alpha channel. |
full_page_max_height | int | Caps a full_page capture. An infinite-scroll page has no natural end without it. |
full_page_readiness | string | scroll walks a full_page capture down before taking it, so loading=lazy images and content that loads as it comes into view are in the picture. The walk takes at most 10 seconds, waits at most 0.75 s for each step, and stops at full_page_max_height. A budget that runs out is a warning on a successful capture. Requires full_page. |
success_rules | object | Rules for output that should count as a failure, such as an error page or a missing element. See Success rules. |
Before capture, Ironfang Render waits for web fonts, advances CSS animations and transitions to their final state, and hides scrollbars. Looping animations continue running.
Sites with bot protection may serve a verification page to automated browsers. Ironfang Render returns the content supplied by the site and does not attempt to bypass the verification step. When it recognises one with confidence, the response carries the warning bot_challenge; not every verification page is recognised.
Use delay_ms when content continues rendering after the standard page-settling process. Common examples include dashboards that fetch data after load and pages animated by JavaScript libraries such as GSAP.
If elements are missing or half-faded in your capture, try delay_ms between 500 and 1500.
curl -X POST https://api.ironfang.com/render/v1/screenshot \
-H "Authorization: Bearer if_live_..." \
-d '{"url": "https://example.com", "full_page": true, "dark_mode": true}' \
--output page.pngDevices
The device field applies a viewport, pixel density, mobile identity and user agent together. This provides more accurate device emulation than changing the viewport width alone.
| Device | Viewport | Pixel ratio | Image size |
|---|---|---|---|
desktop | 1280 × 800 | 1× | 1280 × 800 |
tablet | 820 × 1180 | 2× | 1640 × 2360 |
mobile | 390 × 844 | 3× | 1170 × 2532 |
Viewport dimensions use CSS pixels. An explicit width or height overrides the corresponding preset value. An unrecognised device name returns a validation error.
Device presets use Chromium device emulation. They do not bypass bot protection, so a site may still return a verification page.
curl -X POST https://api.ironfang.com/render/v1/screenshot \
-H "Authorization: Bearer if_live_..." \
-d '{"url": "https://example.com", "device": "mobile", "format": "webp"}' \
--output phone.webpOptions for every render
These options apply to screenshots, PDFs and site previews. Omitting an option preserves the default response behaviour.
Blocking
Ads and trackers are blocked as network requests before they load. Consent banners are page elements inserted by scripts, so Ironfang Render hides supported banners with CSS after page load.
| Field | Type | Notes |
|---|---|---|
block_ads | bool | Prevents requests to known ad and tracker networks. Ironfang Render uses a focused blocklist to reduce the risk of removing resources needed by the page. |
block_cookie_banners | bool | Hides supported consent frameworks and removes their page scroll locks. This allows full_page captures to extend beyond one viewport. Ironfang Render does not click an accept button or record consent. |
hide_selectors | string[] | CSS selectors to hide. Applied after load, so elements injected by script are covered. |
Custom CSS, JavaScript and page actions
Hide a widget, adjust print styles, select a tab or expand content before a screenshot or PDF. These options work with a URL or supplied HTML, including queued jobs and batches.
After the normal page load and waiting conditions, Ironfang Render applies built-in hiding, then your css, awaits your script, and runs actions in order. Settling and capture happen afterwards. Top-level wait_for_selector runs before these controls; use a selector action to wait for content produced by an interaction.
| Field | Type | Limits and behavior |
|---|---|---|
css | string | A stylesheet up to 65,536 UTF-8 bytes. Supports layout overrides, hidden elements, disabled animations and @media print. Malformed syntax returns 400 bad_request before rendering. Chromium determines supported property names and values. |
script | string | JavaScript up to 16,384 UTF-8 bytes, executed inside the target page's main-frame context as an async function body. Use await or return a promise for asynchronous work. Execution is limited to five seconds or the remaining render timeout, whichever is shorter. Return values are discarded. |
actions | object[] | Up to 20 ordered actions sharing the remaining timeout_ms budget. Selectors target the first match in the main page and are limited to 1,024 UTF-8 bytes. |
| Action type | Required field | Behavior |
|---|---|---|
click | selector | Wait for an interactable, enabled element and click it. |
hover | selector | Wait for an interactable element and move the pointer over it. |
wait_for_selector | selector | Wait for an element to exist; it may be hidden. |
delay | duration_ms | Wait 0-10,000 ms. All action delays together may total at most 10,000 ms. |
This self-contained example hides a widget, changes the heading and expands pricing details before capture. Replace html with url and use your site's selectors to capture an existing page.
curl https://api.ironfang.com/render/v1/screenshot \
-H "Authorization: Bearer $IRONFANG_API_KEY" \
-H 'Content-Type: application/json' \
--output expanded.png --data-binary @- <<'JSON'
{
"html": "<h1 id='title'>Plans</h1><div class='chat-widget'>Chat widget</div><details id='details'><summary id='show-more'>Show pricing</summary><p>Expanded pricing details</p></details>",
"css": ".chat-widget { display: none !important; }",
"script": "document.querySelector('#title').textContent = 'Pricing';",
"actions": [
{ "type": "click", "selector": "#show-more" },
{ "type": "wait_for_selector", "selector": "#details[open]" }
]
}
JSONUse /v1/pdf and --output expanded.pdf for the same page state in a PDF. For asynchronous JavaScript, use an awaited operation, for example await new Promise(resolve => setTimeout(resolve, 250));. For UI that arrives later, follow the interaction with a selector action.
Invalid inputs return 400 bad_request. Script syntax errors, execution failures and timeouts return 422 render_failed with a sanitized message. Action errors identify the zero-based index and type, such as actions[0] (click), and stop the list. Failed renders count nothing; queued jobs do not retry these interaction failures.
These options and action order are part of the screenshot cache key. A cache hit returns the previous image without executing controls again; set no_cache: true for a fresh run. Scripts and clicks can change the target application's state. The API exposes page-context snippets and these four actions; it does not provide browser sessions or Playwright/Puppeteer APIs.
Headers, cookies and credentials
Ironfang Render applies headers, cookies and credentials before navigation so they are present on the first request to the target page.
| Field | Type | Notes |
|---|---|---|
headers | object | Extra HTTP headers, sent with every request for the page. |
authorization | string | Sets the Authorization header. An explicit Authorization entry in headers takes precedence. |
cookies | object[] | { name, value, domain, path }. domain is required because Chromium drops a domainless cookie set before navigation. |
user_agent | string | Overrides the user agent, including one set by device. |
Private-network targets remain blocked when credentials are supplied. The SSRF guard runs before authentication data is applied.
Language and clock
Set the browser's language and clock before the page's scripts run, so the page formats dates, numbers and currencies as a visitor there would see them.
| Field | Type | Notes |
|---|---|---|
locale | string | A BCP 47 tag such as en-GB or de-DE. Sets the default for Intl formatting, navigator.language and navigator.languages, and the Accept-Language the page and its resources are requested with (de-DE,de;q=0.9). |
timezone | string | An IANA time zone such as Europe/London or America/New_York. Sets the page's clock: Date, Intl.DateTimeFormat and anything that reads local time, with the zone's own daylight saving. Default UTC. |
curl -X POST https://api.ironfang.com/render/v1/screenshot \
-H "Authorization: Bearer if_live_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/checkout", "locale": "de-DE", "timezone": "Europe/Berlin"}' \
--output checkout-de.pngAn Accept-Language you set in headers is yours and is sent exactly as given. The browser cannot then report a different language in navigator, so navigator keeps its default, while Intl formatting still follows locale. Every response says what the page actually used in X-Ironfang-Locale and X-Ironfang-Timezone, and jobs record it in their diagnostics. Both options apply to screenshots, PDFs, capture bundles and jobs, and to signed screenshot URLs.
Language, clock, location and network address are four separate things. locale and timezone change only what the browser says about its language and clock. They do not grant the Geolocation API a position, and they do not change where the request comes from: every render is made from Ironfang's own network, whatever the page is told. A site that decides by IP address what to show will still see Ironfang's network. Ironfang Render does not route requests through other countries.
Where renders come from
Renders are made from Ironfang's own network. Ironfang does not currently publish a fixed set of outbound addresses for you to allowlist, so do not build access control on the address a render arrives from. To let Render through a login, a firewall or bot protection on a site you control, give it something your site already checks:
- A session cookie. Sign in as a service user, then send its session cookie in
cookieswith the cookie's owndomain. Use an account that can only see what the capture needs. - A token. Send an API token or bearer credential in
authorization, or any header your application authenticates with inheaders. - A shared secret header. Choose a long random value, send it in
headers(for exampleX-Render-Access), and have your site or its edge let requests with that value past bot protection. Rotate it like any credential.
curl -X POST https://api.ironfang.com/render/v1/screenshot \
-H "Authorization: Bearer if_live_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://app.example.com/reports/weekly",
"cookies": [{"name": "session", "value": "SERVICE_USER_SESSION", "domain": "app.example.com"}],
"headers": {"X-Render-Access": "LONG_RANDOM_SECRET"}}' \
--output weekly.pngCredentials are sent only to the page and the resources it loads, never stored with request history, and private-network targets stay blocked whatever you send. A site you do not control may block automated browsers deliberately; Ironfang Render does not try to get around that, and a page that answers with a challenge is reported as bot_challenge.
Waiting
| Field | Type | Notes |
|---|---|---|
wait_until | string | load (default), domcontentloaded or networkidle. networkidle waits for traffic to stop and is bounded by the render timeout, because a page that polls never truly idles. |
wait_for_selector | string | Wait for this element before capturing. The request fails if the element does not appear before the timeout. |
timeout_ms | int | Per-render timeout. Values above the service maximum are clamped to that maximum. |
device_scale_factor | number | Pixel density, 1 to 3. 2 is retina: the same CSS size at twice the pixels. |
curl -X POST https://api.ironfang.com/render/v1/screenshot \
-H "Authorization: Bearer if_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"block_ads": true,
"block_cookie_banners": true,
"wait_for_selector": "#main",
"device_scale_factor": 2
}' \
--output shot.pngSuccess rules
A render that produces bytes is a success to the browser, whatever the page said. With success_rules on a screenshot or PDF, a captured error page, login wall or empty page fails instead. Rules are optional: without them a render behaves as it always has.
| Rule | When it is checked | Fails when |
|---|---|---|
fail_on_status | When the page answers, on its final status after redirects, before any wait for the page to become ready | The status is in the list: a code (404), a class ("5xx") or a range ("400-499"). Needs url. |
require_selector | Once the page is ready, before capture | No element matches the CSS selector. It does not wait; combine it with wait_for_selector. |
require_text | Once the page is ready, before capture | Any of the strings is not displayed on the page. Literal, ignoring case and runs of whitespace; hidden text does not count. |
forbid_text | Once the page is ready, before capture | Any of the strings is displayed on the page, matched as require_text. |
fail_on_blank | After capture, screenshots only | The image is a single flat colour: at most one pixel in ten thousand differs from the most common colour. A white page with any text, a canvas drawing or an image is not blank. |
curl -X POST https://api.ironfang.com/render/v1/screenshot \
-H "Authorization: Bearer if_live_..." \
-d '{
"url": "https://example.com/pricing",
"success_rules": {
"fail_on_status": ["4xx", "5xx"],
"require_selector": "#plans",
"forbid_text": ["Sign in to continue"]
}
}' \
--output pricing.pngA failed rule is 422 success_rule_failed, with error.rule naming the rule, error.target_status the page's status and a message naming the value that failed. A job fails at once and is not retried. Like every failed render, it counts nothing.
Rules are part of the request, so a cached capture is only served to a request with the same rules.
POST /v1/pdf
Print a URL or raw HTML to PDF. Each successful PDF counts once against render.pdf, with every page up to 100 pages included. A document that prints to more is refused with 422 pdf_too_long, and nothing is counted.
| Field | Type | Notes |
|---|---|---|
url | string | Page to print. Provide url or html. |
html | string | Raw HTML to print instead of a URL. |
landscape | bool | Landscape orientation. |
print_background | bool | Include CSS backgrounds. |
header_html | string | Chromium header template. Setting either template turns headers and footers on, so when you send only a footer, send an empty header such as <span></span>; otherwise Chromium prints its default date and title header. |
footer_html | string | Chromium footer template. The pageNumber, totalPages, date, title and url classes are filled in. Templates do not inherit the page's styles, so set a font size in each. |
scale | float | Print scale. Default 1.0. |
no_cache | bool | Accepted, with no effect here: PDFs are never cached, so every request renders the document afresh. |
paper_format | string | a3, a4, a5, letter, legal or tabloid. Omit for Chromium's default, Letter. Sizes are portrait; landscape rotates them. |
margin | object | { top, right, bottom, left } in inches. Omitted sides retain Chromium's default margin. |
paper_width / paper_height | string | A custom page size, such as 210mm, 8.5in, 21cm, 595pt or 794px, from 1 to 100 inches a side. Give both or neither, and never with paper_format. landscape rotates it. |
prefer_css_page_size | bool | Let the page's own @page { size: ... } decide the size. A page without one keeps the size above. landscape does not rotate a CSS size. |
page_ranges | string | Pages to keep, numbered from 1: 1-3, 5, 8-. Each page is kept once, in document order. Pages past the end are dropped; ranges that keep none are 422 page_range_empty. The whole document must still be within the page bound. |
metadata | object | The PDF's document properties: title, author, subject and keywords, up to 500 characters each. Without a title, the page's own title is kept. |
success_rules | object | As for screenshots, except fail_on_blank. See Success rules. |
curl -X POST https://api.ironfang.com/render/v1/pdf \
-H "Authorization: Bearer if_live_..." \
-d '{"html": "<h1>Invoice #42</h1>", "print_background": true}' \
--output invoice.pdfPOST /v1/qr
Generate styled QR codes that are verified before delivery. Ironfang Render decodes each finished image and compares it with the supplied payload. Failed verification returns 422 unscannable, and nothing is counted. Each QR code counts against render.qr, a free utility: it appears in usage and is never charged, including inside templates. QR requests are rate limited.
| Field | Type | Notes |
|---|---|---|
data | string | Required payload containing a URL, WiFi string or text up to 1KB. |
size | int | Square output size from 64 to 2048 pixels. Default 512. PNG only. |
ecc | string | Error correction: L, M, Q or H. Default M. Adding a logo automatically selects H to compensate for the modules covered by the logo. |
dark / light | string | Hex colours. dark must be darker than light and meet the minimum contrast requirement. Invalid combinations return a 400 response. light also accepts transparent. Place transparent codes on a light background. |
dots | string | square or circle. Finder patterns always render solid to preserve their required structure. |
eyes | string | square or rounded. Defaults to match dots. |
margin | int | Quiet zone from 0 to 8 modules. Default 4. |
invert | bool | Creates light modules on a dark background. Modern phone cameras often read inverted codes, but many embedded and in-app scanners do not. With this option, dark must be lighter than light. Ironfang Render verifies the result against its negative. |
logo | string | Centre mark as a base64 data: URI, png or jpeg. Mutually exclusive with logo_url. |
logo_url | string | Centre mark supplied by URL. The URL uses the same target guard and rate limits as a screenshot request. |
logo_size | float | Logo tile as a fraction of the symbol, from 0.12 to 0.30. Default 0.22. |
logo_pad | bool | Adds a knockout tile behind the logo to separate it from the QR modules. Default true. |
curl -X POST https://api.ironfang.com/render/v1/qr \
-H "Authorization: Bearer if_live_..." \
-d '{"data": "https://example.com/menu", "dots": "rounded",
"logo_url": "https://example.com/logo.png"}' \
--output qr.pngTo include a QR code in a stored template, use the qr object on template renders. QR output never carries a badge, because an added mark could affect scanning.
POST /v1/video
A clip: a background, captions that appear on a schedule, an optional watermark and an optional audio bed. Returns video/mp4.
Ironfang Render generates short clips from structured data for automated content workflows. Requests use the same API key, billing account and integrations as other render types.
| Field | Type | Notes |
|---|---|---|
size | string | vertical 1080×1920 (default), square 1080×1080, landscape 1920×1080, 720p 1280×720. |
duration | number | Seconds of output, 1 to 60. Default 15. |
captions | array | Up to 12 cards of {text, from, to}, in seconds. Text may include quotes, colons and brackets. |
colour | string | Hex background, used when no background is given. Default #101820. |
background | string | URL of an image or video, cropped to fill the canvas. |
watermark | string | URL of a PNG, placed bottom right. |
audio | string | URL of an audio track, trimmed to the clip. |
font_size | int | Caption size in pixels. Defaults to a fifteenth of the width. |
curl -X POST https://api.ironfang.com/render/v1/video \
-H "Authorization: Bearer if_live_..." \
-d '{
"size": "vertical",
"duration": 15,
"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}
]
}' --output clip.mp4Usage
A clip counts its output seconds, rounded up per clip: vertical and landscape clips are 1080p and count against render.video_second_fhd, while square and 720p clips count against render.video_second_hd. The duration is reserved before the encode and committed when the clip is delivered. A clip that fails to render counts nothing, and neither does a cache hit.
Operational limits
Clips are rendered inside the request and capped at 60 seconds. Referenced assets use the same URL checks as screenshot targets and are capped at 64 MB each. Background assets must be available from the public internet.
Clips are limited to 60 seconds. /v1/video renders one inside the request; to queue it instead and have a webhook tell you when it finishes, submit it as a job, described below. Longer clips are not currently supported.
POST /render/v1/site-preview
Turn a public webpage into a top-to-bottom preview. Choose a continuous sweep or flick-eased, viewport-sized steps that settle into a short reading pause. One request returns a JPEG poster from the first frame and a browser-recorded H.264 MP4. Fixed and sticky elements behave as they do during a real scroll.
This endpoint is available only at https://api.ironfang.com/render/v1/site-preview.
| Field | Type | Notes |
|---|---|---|
url | string | Public HTTP or HTTPS page to record. Required. |
width | int | Even output width from 320 to 1280. Default 672. |
height | int | Even output height from 240 to 1200. Default 494. The frame is limited to 1,200,000 pixels. |
motion | string | per_page for viewport-sized steps and pauses, or single_sweep for one continuous pass. Default per_page. |
device | string | desktop, tablet or mobile. |
dark_mode | bool | Record with the page's dark colour scheme. Default false. |
no_cache | bool | Force a fresh recording. Default false. |
Run this from your server, never from browser code: the request carries your API key, and anything shipped to a browser is public. For a preview a visitor triggers, mint a signed URL server-side instead.
const response = await fetch(
'https://api.ironfang.com/render/v1/site-preview',
{
method: 'POST',
headers: {
Authorization: 'Bearer if_live_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://example.com',
motion: 'per_page'
})
}
);
const output = await response.formData();
const poster = output.get('poster'); // poster.jpg, image/jpeg
const video = output.get('video'); // preview.mp4, video/mp4Output and usage
The response is multipart/form-data with named poster and video parts. Video is 60fps H.264 MP4 with a first-frame JPEG poster. Ironfang Render calculates the duration from the page height and motion, with a 60-second server limit. A preview counts its output seconds, rounded up, against render.site_preview_second: the 60-second limit is reserved before the recording starts, and the real length is committed when the preview is delivered. The poster is included. Failed work counts nothing, and neither does a cache hit.
A preview is recorded in real time, so the request stays open while it renders: typically 30 to 90 seconds, longer for tall or heavy pages. Give your HTTP client a timeout of at least 120 seconds, or submit the same request as a job and poll for the result. A preview that does not finish within 90 seconds is 422 render_timeout, as a request or as a job: the page is too slow or too long to record.
POST /render/v1/bundle
Load a page once and take the outputs you choose from that one load: a screenshot, the rendered_html, the page's text, markdown and its metadata. Every screenshot option applies, from devices and full page to readiness, actions and success rules.
This endpoint is available only at https://api.ironfang.com/render/v1/bundle, and as a job of kind bundle.
| Field | Type | Notes |
|---|---|---|
outputs | array | Required. Any of screenshot, rendered_html, text, markdown and metadata. format, quality, selector, clip, omit_background and fail_on_blank shape the image, so they need screenshot. |
curl -X POST https://api.ironfang.com/render/v1/bundle \
-H "Authorization: Bearer if_live_..." \
-d '{"url": "https://example.com", "full_page": true,
"outputs": ["screenshot", "markdown", "metadata"]}' \
--output bundle.multipartconst response = await fetch('https://api.ironfang.com/render/v1/bundle', {
method: 'POST',
headers: { Authorization: 'Bearer if_live_...', 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', outputs: ['markdown', 'metadata'] })
});
const parts = await response.formData();
const manifest = JSON.parse(await parts.get('manifest').text());
const markdown = await parts.get('markdown')?.text();
for (const output of manifest.outputs) {
if (output.status !== 'included') console.warn(output.name, output.status, output.reason);
}What each output is
rendered_html is the document as the browser has it after its scripts, readiness and actions, serialised with its doctype. It is not the source the server sent. text is what the page displays, as laid out. markdown is made from the rendered HTML without any model: headings, paragraphs, links made absolute, images by address, emphasis, code, quotes, lists and tables. Scripts, styles, form controls and markup marked hidden are left out. metadata is the title, description, language, canonical address, robots and Open Graph properties.
One navigation, one moment
The HTML, text and metadata are read together, in one step no page script can run inside, so they describe one state of the page. The screenshot is taken straight after. A page that changes itself, such as a clock or a live feed, can change in between: a bundle is the page as the browser had it, not a snapshot of the site's data. The manifest gives both times.
HTML, text and Markdown come from the main frame. The content of iframes is not included, from any origin; child_frames in the manifest counts the frames left out. The screenshot shows what was painted, frames included.
Limits and partial bundles
Rendered HTML is kept up to 5MB, and text and Markdown up to 1MB each. Text and Markdown over their limit are cut at a character boundary and marked truncated. HTML over its limit is omitted, since cutting HTML would make a different document, and so is the Markdown made from it. Every requested output is listed in the manifest with its status, so nothing is left out silently. A bundle left with no output at all is 422 bundle_empty and counts nothing.
Response and usage
The response is multipart/form-data: manifest (JSON) first, then each included output as a file part named by output. The manifest gives each output's size and SHA-256, the capture times and the page's final address and status. A bundle counts once against render.screenshot when it succeeds, whatever outputs it holds, and nothing when it fails. Bundles are never cached.
Captured HTML and text are the page's own content. Treat them as untrusted input, and never insert the HTML into a page of your own.
Templates
Store reusable HTML and render it with different variables for Open Graph images, social cards and other dynamic assets. Variables use {{name}} placeholders. Substituted values are HTML-escaped to prevent markup injection. Template management is free, while each successful image render counts once against render.image. IDs use UUIDv7 and sort by creation time.
| Endpoint | Purpose |
|---|---|
POST /v1/templates | Create. Body: name, html (max 256KB), width, height (default 1200×630, capped 4096). |
GET /v1/templates | List your templates. |
GET /v1/templates/{id} | Fetch one. |
PUT /v1/templates/{id} | Update. Edits invalidate the render cache automatically. |
DELETE /v1/templates/{id} | Delete. |
curl -X POST https://api.ironfang.com/render/v1/templates \
-H "Authorization: Bearer if_live_..." \
-d '{
"name": "og-card",
"html": "<div class=\"card\"><h1>{{title}}</h1><p>{{author}}</p></div>",
"width": 1200,
"height": 630
}'POST /v1/image/{id}
Render a stored template as an image with the supplied variables. Each successful image counts once against render.image, and a cache hit counts nothing.
| Field | Type | Notes |
|---|---|---|
vars | object | String map filling the template's {{placeholders}}. Missing vars render empty. |
no_cache | bool | Force a new capture, ignoring any cached render. A fresh render is counted. See Caching. |
format | string | png (default), jpeg or webp. WebP can produce a smaller file than PNG for the same output. |
qr | object | Named QR specs, up to 4, each the shape of the /v1/qr body. Each entry is rendered, scan-verified and injected as a {{qr.<name>}} variable containing a data URI for use in an <img> element. Embedded QR codes add no charge to the image render. |
curl -X POST https://api.ironfang.com/render/v1/image/0198c9f1-5b7a-7c2e-9f1d-3a8b2c4d5e6f \
-H "Authorization: Bearer if_live_..." \
-d '{"vars": {"title": "Hello from Ironfang Render", "author": "Rick"}}' \
--output og.pngFor a QR slot, include <img src="{{qr.pay}}" width="150"> in the template:
curl -X POST https://api.ironfang.com/render/v1/image/0198c9f1-5b7a-7c2e-9f1d-3a8b2c4d5e6f \
-H "Authorization: Bearer if_live_..." \
-d '{"vars": {"invoice": "INV-2041"},
"qr": {"pay": {"data": "https://pay.example/INV-2041", "size": 300}}}' \
--output invoice-card.pngDocument templates
Store a PDF template once and render it with structured JSON data: nested values, loops and conditionals. A template has an editable draft and immutable, numbered revisions. Renders and jobs without a revision use the published one, resolved when they are accepted, so a job renders the revision it was accepted with however the template changes while it waits. Each document counts once against render.pdf, exactly as a PDF does; validating and editing are free.
| Endpoint | Purpose |
|---|---|
POST /render/v1/document-templates | Create a template: name, source, schema, options and sample_data. A source that does not compile is refused. At most 500 templates per account. |
GET /render/v1/document-templates | List your templates, without their sources. ?archived=true includes archived ones. |
GET /render/v1/document-templates/{id} | Fetch the template and its draft. |
PUT /render/v1/document-templates/{id} | Replace the draft. What is published changes only when you publish. |
DELETE /render/v1/document-templates/{id} | Archive the template. It can no longer be edited, published or rendered without a revision number; jobs already accepted still render. |
POST /render/v1/document-templates/{id}/validate | Check the draft against data, or its sample data, without printing or counting anything. The answer says valid, and otherwise which fields or which line is wrong. |
POST /render/v1/document-templates/{id}/preview | Print the draft with data, or its sample data. A PDF render like any other; it answers a PDF only when the render succeeded. |
POST /render/v1/document-templates/{id}/publish | Publish the draft as the next revision. An unchanged draft publishes the latest revision again. |
GET /render/v1/document-templates/{id}/revisions | List the revisions, newest first. |
GET /render/v1/document-templates/{id}/revisions/{n} | Fetch one revision with its source. |
POST /render/v1/document-templates/{id}/revisions/{n}/publish | Publish an existing revision again: a rollback, or forward again. |
POST /render/v1/document-templates/{id}/render | Print a revision, the published one unless revision names another, with data. |
curl -X POST https://api.ironfang.com/render/v1/document-templates \
-H "Authorization: Bearer if_live_..." \
-d '{
"name": "invoice",
"source": "<h1>Invoice {{.number}}</h1><p>{{.customer.name}}</p><table>{{range .lines}}<tr><td>{{.description}}</td><td>{{fixed 2 .amount}}</td></tr>{{end}}</table>{{if .notes}}<p>{{.notes}}</p>{{end}}",
"schema": {
"type": "object",
"required": ["number", "customer", "lines"],
"properties": {
"number": {"type": "string"},
"customer": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}},
"lines": {"type": "array", "items": {"type": "object", "required": ["description", "amount"],
"properties": {"description": {"type": "string"}, "amount": {"type": "number"}}}},
"notes": {"type": "string"}
}
},
"options": {"paper_format": "a4", "print_background": true}
}'
curl -X POST https://api.ironfang.com/render/v1/document-templates/{id}/publish \
-H "Authorization: Bearer if_live_..."
curl -X POST https://api.ironfang.com/render/v1/document-templates/{id}/render \
-H "Authorization: Bearer if_live_..." \
-d '{"data": {"number": "INV-0042", "customer": {"name": "Example Trading Ltd"},
"lines": [{"description": "Widgets", "amount": 12.5}]}}' \
--output invoice.pdfThe template language
Sources use Go's html/template syntax: {{.customer.name}}, {{range .lines}}...{{end}}, {{if .notes}}...{{end}}, with {{else}} for an empty list. Each value is escaped for where it lands: text, an attribute, a URL (a script URL becomes #ZgotmplZ), CSS or a script. Besides the comparisons and len, index and slice, a template has fixed for decimal places, mul, add, upper, lower and printf. A template cannot reach the network, files or anything but its data, and cannot call itself. A render stops at 100,000 loop iterations, 8MB of HTML or 2 seconds.
Declaring the data
A template declares the data it needs in schema, a strict subset of JSON Schema: type, properties, required, items, maxItems, maxLength, enum, nullable, default and description. Data is checked before anything prints, and every problem comes back as 422 data_invalid with fields naming each path, such as lines[2].quantity, at no cost. A required field is never defaulted. An optional field missing from the data gets its default, or null, so {{if .notes}} is a test. A field the template reads that the data does not have is an error, never a blank invoice field.
Printing long documents
A template keeps its PDF options with each revision: paper size, orientation, margins, backgrounds, and header_html and footer_html with page numbers. Keep thead { display: table-header-group } so a long table repeats its header row on every page, and tr { break-inside: avoid } so no row is split. break-before: page starts a section on a new page. A system font stack prints at once; a web font from an https:// URL makes the render wait for it. Keep sample data synthetic: it is stored with the template.
Signed URLs
Create a stable GET URL for use in an <img> element or og:image meta tag. The URL does not expose the API key or require an application proxy. Its signature covers every parameter and the account used for metering, preventing changes after signing.
POST /v1/sign with:
| Field | Type | Notes |
|---|---|---|
kind | string | screenshot or image. |
url | string | Target page (kind: screenshot). |
template | string | Template id (kind: image). |
vars | object | Template variables (kind: image). |
width / height | int | Optional dimensions. |
full_page | bool | Screenshots only. |
full_page_readiness | string | Screenshots only, with full_page. |
success_rules | object | Screenshots only. Signed into the URL, so a link that would capture an error page answers 422 instead. |
ttl_hours | int | Expiry in hours. Omitted: 30 days. 0 = never expires. |
The response contains the full url (and its path). Fetching it renders on demand and counts against your account as the matching endpoint does: a signed screenshot against render.screenshot, a signed image against render.image. A fetch served from cache counts nothing.
curl -X POST https://api.ironfang.com/render/v1/sign \
-H "Authorization: Bearer if_live_..." \
-d '{"kind": "image", "template": "0198c9f1-5b7a-7c2e-9f1d-3a8b2c4d5e6f", "vars": {"title": "My post"}, "ttl_hours": 0}'
{"url": "https://api.ironfang.com/v1/r/0/a1b2c3...?kind=image&template=0198c9f1-5b7a-7c2e-9f1d-3a8b2c4d5e6f&v.title=My+post"}Jobs
Every render above can also run as a durable job: submit it, get a 202 back at once, poll until it finishes, then collect the result. Use jobs for clips, slow pages, and anywhere you would rather not hold a connection open. The synchronous endpoints are unchanged.
POST /render/v1/jobs
Idempotency-Key: catalogue-42-v1
{
"kind": "screenshot",
"request": { "url": "https://example.com", "full_page": true },
"external_id": "catalogue-42"
}kind is screenshot, pdf, qr, image, clip, site_preview, document or bundle, and request is exactly that endpoint's body (an image job names its template inside request; a document job's request is template, an optional revision and data). The submission is validated as the synchronous endpoint would validate it, before anything is reserved.
Polling and results
GET /render/v1/jobs/{id} returns the job with status of queued, running, cancellation_requested, succeeded, failed or cancelled. A succeeded job carries result - content type, size, SHA-256, expiry - and a signed url that needs no API key and is valid for 15 minutes; GET .../result redirects to a fresh one. The hosted result is kept for 24 hours after success, privately, and then removed: this is a collection window, not asset hosting. Store what you need on your side.
Idempotency, cancellation and usage
Send Idempotency-Key on every submission. The same key with the same request returns the existing job; the same key with a different request is a 409 idempotency_conflict. DELETE .../jobs/{id} cancels: a queued job stops at once, a running one at its next safe point.
The most a job can count is reserved on your billing account when it is accepted, in the same step, so a job you were told about always holds its reservation and a refusal leaves no job. That one reservation is kept across retries and committed, with what the job produced, when it succeeds; a site preview reserves the 60-second limit and commits its real length. A job that fails, is cancelled while queued, or is cancelled while running without a usable output counts nothing. The job's usage shows its meter and the quantity reserved and committed. Transient failures are retried up to three times with backoff; a page that refuses to render is failed at once with the reason.
Batches
Up to 100 jobs in one submission: a shared default request plus items that override parts of it. Useful when what you actually have is a list of URLs and one set of options.
POST /render/v1/batches
Idempotency-Key: nightly-2026-08-28
{
"kind": "screenshot",
"default": { "full_page": true, "block_ads": true },
"external_id": "nightly",
"items": [
{ "request": { "url": "https://example.com/a" }, "external_id": "a" },
{ "request": { "url": "https://example.com/b" }, "external_id": "b" },
{ "kind": "pdf", "request": { "url": "https://example.com/terms" } }
]
}The merge is one level deep: an item's field wins, everything else comes from the default. An item may also override kind and carry its own delivery.
A batch is accepted or refused whole. Every item is validated before any is stored, and every item is admitted on your billing account in one step - so a batch any item of which cannot be admitted is refused with the billing code, reserves nothing and leaves no jobs behind. Each item then commits or releases on its own as its job finishes. A validation error names the item it came from.
Items are ordinary jobs: poll each one, or poll GET /render/v1/batches/{id} for counts by state, done, and the full job resource for every item. There is no ZIP of results - configure a storage destination if you want the output collected in one place, which is also the only version of this that works when the batch is 100 clips.
Delivery
A job can do more than sit and wait to be collected. Register a destination - a webhook endpoint, or your own S3-compatible bucket - and name it by id from any job. Credentials are sent to us once, when the destination is created, and never travel in a job body.
POST /render/v1/destinations
{ "type": "webhook", "name": "Production", "url": "https://hooks.example.com/render" }
→ 201 { "id": "0198f0a1...", "signing_secret": "...", "signing_secret_note": "store this now; it is not shown again" }The signing secret is shown exactly once. We keep only an encrypted copy, so there is no endpoint that can return it to you later; if you lose it, create a new destination. Managing destinations needs the render:destinations scope.
POST /render/v1/jobs
{
"kind": "screenshot",
"request": { "url": "https://example.com" },
"external_id": "catalogue-42",
"delivery": {
"webhook_destination": "0198f0a1...",
"storage_destination": "0198f0b2...",
"storage_key": "captures/{date}/{external_id}.png"
}
}Signed webhooks
A webhook destination is called on render.job.succeeded, render.job.failed and render.job.cancelled. The body carries the job, its usage, and - on success - the result's content type, size, SHA-256 and a signed url good for 15 minutes from the moment we sent it, not from when the job finished.
Verify the signature over the raw bytes you received, not over anything you parsed and re-serialised. The key is the signing secret decoded from hex - its 32 bytes, not its 64 characters. The SDKs' verifyWebhook takes the secret as shown and does this for you, from version 3.0.1:
key = hex_decode(signing_secret) # 64 hex characters -> 32 bytes
expected = "v1=" + hmac_sha256(key, timestamp + "." + raw_body).hexdigest()
compare(expected, headers["Ironfang-Signature"]) # constant timeIronfang-Timestamp is Unix seconds; reject anything more than a few minutes old to stop a replay. Ironfang-Event-Id is stable across retries of the same delivery, so use it to make your handler idempotent.
Retries follow a ladder from one minute out to a day, with jitter, honouring a bounded Retry-After. We retry 408, 429 and 5xx; other 4xx stop immediately, because they are your endpoint saying a retry will not help. After 10 consecutive failures the destination is disabled until you enable it again in the portal.
Delivery to your own bucket
An s3 destination takes a bucket, a region, credentials and an optional endpoint and prefix. Anything S3-compatible works: AWS, Cloudflare R2, MinIO, Ceph. Scope the credentials to the prefix you give us - they only ever need to write there.
storage_key is the object key relative to that prefix. It takes fixed text plus {job_id}, {external_id} and {date} (YYYY/MM/DD) - no other field, and nothing evaluated. It defaults to render/{date}/{job_id}. A key that would climb out of the prefix is refused when the job is submitted, rather than quietly rewritten into a different key.
An upload is checksummed and verified after writing, and an object already there with the same checksum is left alone - so a retried delivery is a no-op rather than a second write.
Delivery never changes a render
This is the part worth relying on. A job that rendered is succeeded whatever your endpoint did afterwards: a webhook that cannot be reached does not spend a render attempt, hold a render slot, or turn a successful job into a failed one. Deliveries retry on their own ladder, and GET /render/v1/deliveries shows where each one got to.
If a storage delivery gives up, the webhook destination is told with a render.delivery.failed event naming the destination and the error - which is how you find out that a bucket stopped accepting uploads without watching for absent files.
Recipes
Use these end-to-end guides for common Ironfang Render workflows.
- Generate Open Graph images from reusable templates: store one template, create a signed URL for each page, and update the design without replacing the URLs.
- Generate invoice PDFs from existing HTML: use existing invoice HTML and CSS with print backgrounds, page numbers, headers and footers.
- Screenshot a page behind a login: provide the session with the render request to capture the authenticated page.
- Capture screenshots without ads or cookie banners: block network requests and hide consent overlays without recording consent.
- Screenshots in GitHub Actions: capture deployment previews during pull request workflows for review.
Choose a language quickstart for Python, Node.js, PHP and Go.
Usage and billing
Ironfang Render has no plans or credits. Usage is billed to your organisation's Ironfang billing account, which every Ironfang product shares and which you manage in Billing in the portal. Each kind of work counts against a meter:
| Work | Meter | Counts |
|---|---|---|
| Screenshot, signed screenshot | render.screenshot | One per screenshot or image |
render.pdf | One per PDF of up to 100 pages | |
| Template image, signed image | render.image | One per screenshot or image |
| QR code | render.qr | One per code; a free utility, never charged |
Clip, 1080p (vertical, landscape) | render.video_second_fhd | Output seconds, rounded up per clip |
Clip, 720p or square | render.video_second_hd | Output seconds, rounded up per clip |
| Site preview | render.site_preview_second | Output seconds, rounded up per preview |
| Capture bundle | render.screenshot | One per bundle, whatever outputs it holds |
Every meter has a free allowance, on every account and with no card, renewed on the first day of each UTC calendar month. Past it, usage is charged at graduated rates once Pay as you go is switched on for the billing account, within the monthly usage limit set for the account and any limit set for Ironfang Render. Prices exclude VAT. The allowances and rates of every meter are on the pricing page.
Every billable request is admitted before any work starts: the most it can count is reserved, and a request that cannot be admitted is refused with nothing done. What the work produced is committed when it succeeds, never more than was reserved; a failure, a refused input or a cancellation commits nothing. Billable responses say what they counted in X-Ironfang-Meter and X-Ironfang-Quantity.
When usage is refused
A billing refusal comes back in the usual error body with a stable code, and nothing is reserved or counted:
429 free_allowance_exhausted: this month's free allowance for the meter is used up and Pay as you go is not switched on. Work on that meter stops until the allowance renews, and nothing is charged.429 account_budget_exhaustedor429 product_budget_exhausted: the account's monthly usage limit, or the limit for Ironfang Render, is reached. Raise it in Billing or wait for the next month.402 paid_usage_paused,payment_required,payment_action_requiredoraccount_restricted: paid usage cannot be billed. Resolve it in Billing.403 account_not_linked: the key's account belongs to no Ironfang organisation, so its usage cannot be billed.403 product_not_eligible: paid use of Ironfang Render needs an eligible billing account.503 billing_temporarily_unavailable: billing could not admit the request just now. Retry after theRetry-Afterseconds.
No badge on your output
Nothing you render through the API carries an Ironfang badge or watermark, whatever your billing state. Only the anonymous free tools on ironfang.com mark their output.
GET /v1/usage
This UTC calendar month's Ironfang Render usage from your organisation's billing account: for every Render meter, what was used, how much of it the free allowance covered, what work in flight holds, and what the settled usage costs before tax.
{
"product": "render",
"period_start": "2026-11-01",
"period_end": "2026-12-01",
"catalogue_version": "2026-11-1",
"mode": "free",
"currency": "GBP",
"settled": "0.00",
"reserved": "0.00",
"meters": [
{
"id": "render.screenshot",
"unit": "screenshot",
"unit_plural": "screenshots",
"free_allowance": 500,
"used": 250,
"free_used": 250,
"billable": 0,
"reserved": 1,
"remaining_free": 249,
"charge": "0.00",
"free_utility": false,
"exempt": false
},
{
"id": "render.qr",
"unit": "QR code",
"unit_plural": "QR codes",
"free_allowance": 0,
"used": 12,
"free_used": 0,
"billable": 12,
"reserved": 0,
"remaining_free": 0,
"charge": "0.00",
"free_utility": true,
"exempt": false
}
]
}used is what was committed this month, free_used how much of it the free allowance covered and billable the rest. reserved is held by work in flight and is not charged on top of settled. charge and settled are pounds before tax, as exact decimal strings. mode is the billing account's: enabled means paid usage beyond the free allowances is on. The example shows two of the meters. The whole account - every product, limits and statements - is /billing/v1.
Rate limits
Two rate limits apply alongside billing. Each render can create multiple requests to the target page, so these limits protect both the target site and the Ironfang Render service.
- 60 renders per minute per target host, counted across all customers. Exceeding it returns
429withtarget_rate_limited. - 120 renders per minute per account. Exceeding it returns
429withrate_limited.
Cache hits do not count towards either limit because they send no target traffic. Both limits are checked after the cache and before admission, so a request blocked by a rate limit reserves and costs nothing. Contact Ironfang if you need a higher limit for a site you own.
Response headers
Every rendered asset reports request timings. X-Ironfang-Render-Ms contains the server-side render time, while X-Ironfang-Delay-Ms contains the requested delay_ms portion. Cache hits carry X-Ironfang-Cache: hit, and live captures carry X-Ironfang-Captured-At.
X-Ironfang-Meter names the meter a billable response counted against, such as render.screenshot, and X-Ironfang-Quantity the quantity it committed: one for a screenshot, PDF or image, output seconds for video, and 0 for a cache hit. Log them to track usage per request without polling /v1/usage.
Caching
An identical request made again within 10 minutes of the render that produced it may be served from cache: it carries X-Ironfang-Cache: hit and X-Ironfang-Quantity: 0, and nothing is reserved or counted. The cache is per instance and bounded in size, so an entry can be evicted sooner and a hit is never guaranteed; after 10 minutes the same request is a new, counted render. PDFs are never cached. Assets return Cache-Control: public, max-age=3600, so browsers and CDNs can hold them for an hour. Editing a template invalidates its cache immediately.
Set "no_cache": true when a workflow requires a new capture, such as evidence collection, archiving or change monitoring. This bypasses the cache, and the fresh render is counted. The response includes X-Ironfang-Captured-At with the UTC capture time and Cache-Control: no-store to prevent downstream caching.
Diagnostics
A render reports what it learned about the page, so a successful capture of a 404 page can be told apart from an API error. A synchronous render returns it as response headers; the binary body is unchanged. A job, its webhook and its request history row carry the same as diagnostics.
| Header | Meaning |
|---|---|
X-Ironfang-Target-Status | The page's own HTTP status, after redirects. Absent for raw HTML, which has no response; a status is never invented. |
X-Ironfang-Target-Redirects | How many redirects the page followed, when it followed any. |
X-Ironfang-Settle / X-Ironfang-Settle-Ms | How the page settled before capture (stable, ceiling, fixed or skipped), and how long it took. |
X-Ironfang-Warnings | Codes for output that is complete as a render but may not be the whole page: height_capped, scroll_budget_exhausted, images_incomplete, images_broken and bot_challenge. New codes may be added. |
A cache hit carries the original capture's diagnostics, with X-Ironfang-Captured-At saying when it was made, so an old capture is never presented as a new one.
Errors
Every response carries X-Ironfang-Request-ID, and error bodies repeat it as request_id. Quote it when you contact support and we can find the exact request. If you send your own X-Request-ID (printable ASCII with no spaces, up to 128 characters) it is echoed back unchanged as a separate correlation value.
If errors look like ours rather than your request, the service status page says whether anything is wrong, and support can find the request from its ID.
Every error is JSON with a stable machine-readable code:
{
"error": {
"code": "target_rate_limited",
"message": "too many renders of that site per minute - Ironfang Render caps how much traffic it sends to any one target",
"docs": "https://ironfang.com/render/docs#errors"
},
"request_id": "01a0c375-71de-7a40-8e19-2c7b5d9f0a63"
}docs is a link to this section, and request_id is the X-Ironfang-Request-ID of the response that carried the error.
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | Malformed JSON or invalid parameters. |
| 401 | invalid_api_key | Missing or unknown API key. |
| 402 | paid_usage_paused | Paid usage cannot be billed. payment_required, payment_action_required and account_restricted are the same refusal for other reasons. Resolve it in Billing. |
| 403 | bad_signature | Signed URL failed verification or expired. |
| 403 | account_not_linked | The key's account belongs to no Ironfang organisation, so its usage cannot be billed. |
| 404 | not_found | The template, job, batch, destination, delivery or signed route does not exist in this organisation. |
| 422 | render_failed | Chromium could not render the target because of an invalid URL, blocked private address or selector timeout. |
| 422 | pdf_too_long | The document printed to more pages than a PDF may have. Nothing is counted. |
| 422 | page_range_empty | page_ranges keeps no page of the printed document. Nothing is counted. |
| 422 | bundle_empty | No output of a capture bundle could be included; the message says why for each. Nothing is counted. |
| 422 | success_rule_failed | A success rule failed the render; error.rule names it. Nothing is counted. |
| 422 | data_invalid | A document template's data does not match its schema; fields names each path. Nothing is counted. |
| 422 | template_render_failed | A document template failed while running, for example reading a field the data does not have; the message names the line. |
| 409 | template_unpublished | The document template has no published revision; publish it or name a revision. |
| 429 | free_allowance_exhausted | This month's free allowance for the meter is used up and Pay as you go is not switched on. Nothing is charged; the allowance renews on the first day of the next month (UTC). |
| 429 | account_budget_exhausted | The account's monthly usage limit is reached. product_budget_exhausted is the same for a limit set on Ironfang Render. Raise it in Billing or wait for the next month. |
| 503 | billing_temporarily_unavailable | Billing could not admit the request just now. Retry after the Retry-After seconds; nothing was reserved. |
Machine interfaces
| Interface | Details |
|---|---|
| Product page | https://ironfang.com/render |
| Documentation | https://ironfang.com/render/docs |
| API base URL | https://api.ironfang.com/render |
| OpenAPI contract | https://api.ironfang.com/render/openapi.yaml. The same contract is served as JSON at https://api.ironfang.com/render/openapi.json. |
| Authentication | Platform API key as a bearer token |
| Errors | https://ironfang.com/render/docs#errors. A JSON body with a stable code, a message, this link and the request id. |
| MCP | Available. Screenshots, PDFs, capture bundles, QR codes, template renders, clips, batches, signed render URLs, webhook delivery destinations, jobs and usage. Storage destinations carry credentials and stay in the portal. Tools are named render.*. MCP server reference; every tool and schema without a token at /.well-known/ironfang-mcp.json |
| Discovery | /apis.json, /.well-known/api-catalog and /llms.txt |
Launched as Renderwolf. The API paths, scopes, tool names and headers under that name were retired on 28 September 2026; only the identifiers below, recorded in signed evidence, keep it.

