Render API
The Render API turns a design scene into a PNG, JPEG, WebP or PDF, either from a scene you send or from a saved template whose named slots (texts, colours, images) you fill per request. POST /v1/render/bulk renders one template for up to 1,000 rows at once and returns a ZIP. Renders are drawn with the same engine as the FOTOhub design editor.
Capabilities
- One image or PDF per request, synchronous (
sync: true, up to 10 seconds) or asynchronous (202+ polling or a webhook) - Templates with up to 64 named slots:
text,fill(a colour) orsrc(an image) - Bulk: one template x up to 1,000 rows -> one ZIP, billed per started 100 images, failed images refunded
- Price in the answer: every submit returns its
quote; a failed render is refunded once - Safe retries with
Idempotency-Key - Signed webhooks with a timestamped HMAC signature
Base URL: https://apis.fotohub.appAuthentication: Authorization: Bearer fh_live_your_api_key (see Authentication)
Endpoints
| Method | Path | Charged | Scope | Description |
|---|---|---|---|---|
| POST | /v1/render | yes | render:write | Render one image or PDF |
| POST | /v1/render/bulk | yes | render:write | Render a template once per row, as one ZIP |
| GET | /v1/render/{render_id} | no | render:read | A render job (single or bulk) |
| POST | /v1/templates | no | render:write | Save a template |
| GET | /v1/templates | no | render:read | Your templates |
| GET | /v1/templates/{template_id} | no | render:read | One template, with its scene |
| DELETE | /v1/templates/{template_id} | no | render:write | Delete a template |
| GET | /v1/render/webhook-secret | no | render:write | Your webhook signing secret |
| POST | /v1/render/webhook-secret/rotate | no | render:write | Issue a new signing secret |
Keys and scopes
The Render API needs an API key created with a render scope. render:write covers render:read.
- A key with no scopes, the legacy default set, or
*/allis refused:403witherror: "scope_required"(Rendering needs an API key created with the render scope. Create a new key with the render scope in your dashboard.) andrequired_scope. - A scoped key without the needed scope:
403insufficient_scope(This API key does not have the 'render:write' scope.). - Writes (
render:writeroutes) also need a key with write access; a read-only key gets403This API key is read-only. Use a key created with write access, or the dashboard, to perform this action. - Storage bucket keys cannot use the Render API (
403 insufficient_scope).
The scene
A scene is a JSON object with exactly these top-level fields:
| Field | Description |
|---|---|
v | Must be 1 |
artboard | {width, height} in pixels, each 1-8192 |
objects | Array of design objects, at most 500 |
fonts | Optional |
The easiest way to get a valid scene is to design it in the FOTOhub editor and save it as a template. Limits, all checked before anything is charged:
| Limit | Value |
|---|---|
| Request body | 3 MiB (413 request_too_large) |
| Scene JSON | 1 MiB |
| Objects | 500 |
| Output size | each side at most 8192 px after scale, and at most 25,000,000 pixels |
| Nesting | 24 levels |
Images and fonts. A scene cannot load files from the internet. Every image reference in the scene (src, maskSrc, imageSrc, thumbnailSrc) must be /scene/<name>, where <name> is one of the assets you send with the request. Upload each file first with POST /v1/cpu/inputs/upload-url (see Uploading inputs) and pass the path it returns as an asset:
| Asset field | Type | Description |
|---|---|---|
name | string | A plain file name (^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$), unique within the request |
path | string (3-1024) | The path returned by the upload step (<bucket>/<your user id>/...) |
size | integer | Bytes, 1 to 100 MiB |
content_type | string | Optional MIME type |
At most 16 assets per request. A path that is not in your own storage is refused (403 input_not_owned) before any charge.
Render one image or PDF
curl -X POST https://apis.fotohub.app/v1/render \
-H "Authorization: Bearer fh_live_your_render_key" \
-H "Idempotency-Key: banner-2026-10-09-pl" \
-H "Content-Type: application/json" \
-d '{
"template_id": "3c1f9a52-6d0e-4a7b-9c43-2f5e8d1b7a60",
"slots": {"headline": "Jesienna wyprzedaż -30%", "accent": "#e4572e", "hero": "mug.png"},
"assets": [{"name": "mug.png", "path": "<path from POST /v1/cpu/inputs/upload-url>", "size": 482113, "content_type": "image/png"}],
"format": "png",
"sync": true
}'Request body
| Field | Type | Default | Description |
|---|---|---|---|
scene | object | An inline scene. Send exactly one of scene and template_id | |
template_id | string | A template saved with POST /v1/templates | |
slots | object | {} | Template only: slot name -> value. At most 64; names ^[a-z][a-z0-9_]{0,39}$; text values at most 2000 characters |
format | png | jpeg | webp | pdf | png | |
scale | number 0.1-4 | 1.0 | Multiplies the artboard size |
transparent | boolean | false | Only with png or webp |
quality | integer 1-100 | 90 | JPEG / WebP quality |
assets | array | [] | Files the scene uses (above) |
webhook_url | string (max 512) | Notified when the job finishes (see Webhooks) | |
sync | boolean | false | Wait up to 10 seconds for the result |
A slot value must match its slot's prop: any text for text, a colour like #ff8800 for fill, the name of one of your assets for src. A slot the template does not have, or a value of the wrong kind, is 422 invalid_slots.
Response
202 with the job while it is still running (always, unless sync is true and the render finished within 10 seconds, which answers 200 with the finished job):
{
"id": "0d6a3f0e-8b52-4c1e-a7f3-5e2b9c4d1a77",
"type": "render",
"status": "succeeded",
"progress": 100,
"credits_charged": 0.1,
"refunded": false,
"error": null,
"created_at": "2026-10-09T09:01:12.004Z",
"finished_at": "2026-10-09T09:01:14.512Z",
"expires_at": "2026-10-16T09:01:12.004Z",
"quote": {"credits": 0.1, "usd": 0.001, "units": 0.1},
"output": {"url": "https://s1.fotohub.app/storage/v1/object/sign/...", "content_type": "image/png", "bytes": 318227}
}The numbers are an example. quote is what this request was charged at (see Pricing). output.url is a signed link valid for 7 days.
With sync: true, a render that fails answers 502, refunded:
{
"error": "scene_rejected",
"message": "The render did not complete. You were not charged.",
"charged": false,
"job": { "id": "...", "status": "failed", "refunded": true, "...": "..." }
}Templates
POST /v1/templates saves a scene with named slots:
{
"name": "Autumn banner 1200x628",
"scene": {"v": 1, "artboard": {"width": 1200, "height": 628}, "objects": [ ... ]},
"slots": {
"headline": {"object": "txt-headline", "prop": "text"},
"accent": {"object": "shape-badge", "prop": "fill"},
"hero": {"object": "img-hero", "prop": "src"}
}
}| Field | Type | Description |
|---|---|---|
name | string 1-120 | |
scene | object | A scene as above. It may name assets (/scene/<name>) that each render will send |
slots | object | Slot name -> {object, prop}. object is the id of an object in the scene (1-128 chars); prop is text (default), fill or src |
Answer 201: {"id", "name", "slots", "created_at"}. GET /v1/templates returns {"templates": [...]} (newest first, up to 100, without scenes); GET /v1/templates/{id} adds scene; DELETE answers {"deleted": true, "id": "..."}. Templates are private to your account: someone else's id is 404 not_found. At most 200 templates (409 template_limit). An invalid scene or a slot pointing at a missing object is 422 invalid_template.
Bulk render
POST /v1/render/bulk renders a template once per row. The row keys are slot names. Always asynchronous: 202 with the job.
{
"template_id": "3c1f9a52-6d0e-4a7b-9c43-2f5e8d1b7a60",
"rows": [
{"headline": "Kubek Linen", "accent": "#e4572e", "sku": "MUG-01"},
{"headline": "Kubek Stone", "accent": "#3a6ea5", "sku": "MUG-02"}
],
"format": "jpeg",
"quality": 88,
"name_column": "sku"
}| Field | Type | Default | Description |
|---|---|---|---|
template_id | string | Required. The template must have slots | |
rows | array of objects | Required, 1-1,000 rows; values are strings, numbers, booleans or null; at most 2 MiB in total. Every key must be a slot of the template | |
format, scale, transparent, quality, assets, webhook_url | As for a single render | ||
image_hosts | array of host names | [] | Up to 20 hosts that image slot URLs may be fetched from |
pdf_merge | boolean | false | With format: "pdf", one merged PDF instead of one file per row |
name_column | string 1-40 | The row key whose value names each file in the ZIP |
When it finishes, the job carries:
{
"type": "bulk",
"status": "succeeded",
"download": {"url": "https://s1.fotohub.app/storage/v1/object/sign/...", "expires_in": 604800},
"blocks": {"charged": 1, "billed": 1, "refunded": 0},
"rows": {"rows": 2, "ok": 2, "failed": 0}
}download is a 7-day link to the ZIP. Billing is per started 100 images: the blocks for the declared rows are charged at submit, and blocks that did not render are refunded after the job (at least one block is kept). If no row renders, the whole job fails and is refunded.
Limits for bulk: 20 bulk jobs per hour per account (429 rate_limited, Retry-After: 600, Too many bulk jobs started; try again later. You were not charged.) and 3 running at once.
Read a job
GET /v1/render/{render_id} returns the job object shown above for a single or bulk render. status is queued, running, succeeded, failed, cancelled or refunded. A failed job carries error: {code, message} with code one of render_timeout, scene_rejected, scene_too_complex, input_expired, cancelled or render_failed, and the message The render did not complete. You were not charged. A job that is not yours is 404 not_found. When a webhook was set, the job also carries webhook: {state, attempts, next_at, last_status, last_error, last_at, event}.
Idempotency
Send Idempotency-Key (1-128 printable characters, no leading or trailing spaces) on POST /v1/render and POST /v1/render/bulk. Keys are scoped to your API key.
- Same key, same body: the original job is returned, with
"idempotent_replay": trueand the headerIdempotent-Replay: true. Nothing is charged again. (syncis not part of the comparison.) - Same key, different body:
422 idempotency_key_reused. - Same key while the first request is still running:
409 request_in_progress,Retry-After: 2.
Pricing
Prices are not fixed in this page. A single image is billed as 0.1 of a unit (the rate is per 10 images), a PDF as one document, and a bulk job per started 100 images. Each answer carries the quote it was charged at (credits, usd, units); API keys pay the usd amount from the prepaid wallet. A render that fails is refunded once (refunded: true).
Limits
| Limit | Value |
|---|---|
| Submits per API key | 300 per minute (429 rate_limited, limit_per_minute, Retry-After: 30) |
| Renders per API key per day | 20,000 (a bulk job counts its rows) |
| Renders per account per day | 20,000, across all its keys |
Over a daily quota: 429 quota_exceeded, daily_quota, Retry-After: 3600, The daily render quota of this key is used up. (or of your account). Nothing is charged.
Webhooks
Set webhook_url on a render to be told when it finishes. The URL must be https, on port 443 or 8443, on a public host; otherwise the submit is refused with 422 invalid_webhook_url and a reason (bad_url, https_required, port_not_allowed, blocked_host, blocked_address, dns_failed).
| Event | Sent for |
|---|---|
render.completed / render.failed | POST /v1/render |
bulk.completed / bulk.failed | POST /v1/render/bulk |
A single render's body: {"event", "job_id", "id", "status", "error", "refunded", "credits_charged", "output"}. A bulk body: {"event", "job_id", "status", "error_code", "refunded"} plus, on success, rows, ok, failed, blocks_charged, blocks_billed and download.
Headers:
| Header | Value |
|---|---|
X-FotoHub-Timestamp | Unix seconds |
X-FotoHub-Signature | Lowercase hex HMAC-SHA256 of <timestamp>.<raw body> |
X-FotoHub-Event | The event name |
X-FotoHub-Delivery | <job_id>:<attempt> (not signed; deduplicate on job_id) |
User-Agent | FOTOhub-Webhooks/1 |
Different from other FOTOhub webhooks
Render webhooks sign timestamp + "." + body, not the body alone, and use their own secret. Reject a timestamp more than 300 seconds from your clock.
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.strip().lower())GET /v1/render/webhook-secret returns the secret and how to use it:
{
"secret": "whsec_...",
"generation": 1,
"header": "X-FotoHub-Signature",
"timestamp_header": "X-FotoHub-Timestamp",
"algorithm": "HMAC-SHA256 (hex) of '<X-FotoHub-Timestamp>.<raw request body>'",
"tolerance_seconds": 300
}POST /v1/render/webhook-secret/rotate issues a new generation; deliveries already queued keep the secret they were created with. The same secret signs bulk tool webhooks.
Delivery: any 2xx is success; redirects are not followed; a delivery times out after 15 seconds. Failed deliveries are retried after 30 s, 2 min, 10 min, 1 h, 6 h and 24 h (7 attempts), then marked dead. A URL that stops passing the checks above is not retried.
Errors
These routes answer errors in FastAPI's detail wrapper; every error says whether money moved:
{"detail": {"error": "quota_exceeded", "message": "The daily render quota of this key is used up.", "charged": false, "daily_quota": 20000}}| HTTP | error | When |
|---|---|---|
| 403 | scope_required, insufficient_scope | See Keys and scopes |
| 403 | render_not_enabled | Rendering is not enabled for this account. |
| 403 | input_not_owned | An asset path is not in your storage |
| 404 | not_found | No such render or template. |
| 409 | template_limit | 200 templates already |
| 409 | request_in_progress | The same Idempotency-Key is still running |
| 413 | request_too_large | Body over 3 MiB |
| 422 | invalid_scene, invalid_slots, invalid_template, invalid_request | The message says what is wrong |
| 422 | invalid_idempotency_key, idempotency_key_reused | See Idempotency |
| 422 | invalid_webhook_url (reason), webhooks_unavailable | Webhook URL refused, or webhooks are not available right now |
| 429 | rate_limited, quota_exceeded | See Limits |
| 502 | a failure code | sync: true and the render failed (refunded) |
| 503 | render_unavailable | Rendering is temporarily unavailable. Retry-After, retry_after_s |
| 503 | temporarily_unavailable | Please retry shortly. Retry-After: 5 |
A request body that does not match the schema (an unknown field, scene and template_id both or neither, transparent with jpeg) is a standard 422 validation error.
Related
- CPU Tools: uploading inputs and the other file tools
- Billing & Pricing

