Skip to content

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) or src (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 ​

MethodPathChargedScopeDescription
POST/v1/renderyesrender:writeRender one image or PDF
POST/v1/render/bulkyesrender:writeRender a template once per row, as one ZIP
GET/v1/render/{render_id}norender:readA render job (single or bulk)
POST/v1/templatesnorender:writeSave a template
GET/v1/templatesnorender:readYour templates
GET/v1/templates/{template_id}norender:readOne template, with its scene
DELETE/v1/templates/{template_id}norender:writeDelete a template
GET/v1/render/webhook-secretnorender:writeYour webhook signing secret
POST/v1/render/webhook-secret/rotatenorender:writeIssue 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 * / all is refused: 403 with error: "scope_required" (Rendering needs an API key created with the render scope. Create a new key with the render scope in your dashboard.) and required_scope.
  • A scoped key without the needed scope: 403 insufficient_scope (This API key does not have the 'render:write' scope.).
  • Writes (render:write routes) also need a key with write access; a read-only key gets 403 This 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:

FieldDescription
vMust be 1
artboard{width, height} in pixels, each 1-8192
objectsArray of design objects, at most 500
fontsOptional

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:

LimitValue
Request body3 MiB (413 request_too_large)
Scene JSON1 MiB
Objects500
Output sizeeach side at most 8192 px after scale, and at most 25,000,000 pixels
Nesting24 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 fieldTypeDescription
namestringA plain file name (^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$), unique within the request
pathstring (3-1024)The path returned by the upload step (<bucket>/<your user id>/...)
sizeintegerBytes, 1 to 100 MiB
content_typestringOptional 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 ​

bash
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 ​

FieldTypeDefaultDescription
sceneobjectAn inline scene. Send exactly one of scene and template_id
template_idstringA template saved with POST /v1/templates
slotsobject{}Template only: slot name -> value. At most 64; names ^[a-z][a-z0-9_]{0,39}$; text values at most 2000 characters
formatpng | jpeg | webp | pdfpng
scalenumber 0.1-41.0Multiplies the artboard size
transparentbooleanfalseOnly with png or webp
qualityinteger 1-10090JPEG / WebP quality
assetsarray[]Files the scene uses (above)
webhook_urlstring (max 512)Notified when the job finishes (see Webhooks)
syncbooleanfalseWait 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):

json
{
  "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:

json
{
  "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:

json
{
  "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"}
  }
}
FieldTypeDescription
namestring 1-120
sceneobjectA scene as above. It may name assets (/scene/<name>) that each render will send
slotsobjectSlot 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.

json
{
  "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"
}
FieldTypeDefaultDescription
template_idstringRequired. The template must have slots
rowsarray of objectsRequired, 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_urlAs for a single render
image_hostsarray of host names[]Up to 20 hosts that image slot URLs may be fetched from
pdf_mergebooleanfalseWith format: "pdf", one merged PDF instead of one file per row
name_columnstring 1-40The row key whose value names each file in the ZIP

When it finishes, the job carries:

json
{
  "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": true and the header Idempotent-Replay: true. Nothing is charged again. (sync is 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 ​

LimitValue
Submits per API key300 per minute (429 rate_limited, limit_per_minute, Retry-After: 30)
Renders per API key per day20,000 (a bulk job counts its rows)
Renders per account per day20,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).

EventSent for
render.completed / render.failedPOST /v1/render
bulk.completed / bulk.failedPOST /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:

HeaderValue
X-FotoHub-TimestampUnix seconds
X-FotoHub-SignatureLowercase hex HMAC-SHA256 of <timestamp>.<raw body>
X-FotoHub-EventThe event name
X-FotoHub-Delivery<job_id>:<attempt> (not signed; deduplicate on job_id)
User-AgentFOTOhub-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.

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

json
{
  "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:

json
{"detail": {"error": "quota_exceeded", "message": "The daily render quota of this key is used up.", "charged": false, "daily_quota": 20000}}
HTTPerrorWhen
403scope_required, insufficient_scopeSee Keys and scopes
403render_not_enabledRendering is not enabled for this account.
403input_not_ownedAn asset path is not in your storage
404not_foundNo such render or template.
409template_limit200 templates already
409request_in_progressThe same Idempotency-Key is still running
413request_too_largeBody over 3 MiB
422invalid_scene, invalid_slots, invalid_template, invalid_requestThe message says what is wrong
422invalid_idempotency_key, idempotency_key_reusedSee Idempotency
422invalid_webhook_url (reason), webhooks_unavailableWebhook URL refused, or webhooks are not available right now
429rate_limited, quota_exceededSee Limits
502a failure codesync: true and the render failed (refunded)
503render_unavailableRendering is temporarily unavailable. Retry-After, retry_after_s
503temporarily_unavailablePlease 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.