Skip to Content
Async Renders

Async renders

A render request waits for the render, up to about 95 seconds. If your client cannot wait that long, such as a serverless function with a short timeout, ask for an async answer instead: oJo answers at once and you check back for the result.

These endpoints take it:

  • POST /v1/image/html
  • POST /v1/image/template
  • POST /v1/image/bulk
  • POST /v1/pdf/html
  • POST /v1/pdf/template

Ask for an async answer

Send the header Prefer: respond-async with the same request you would send otherwise.

curl -i -X POST https://api.ojo.so/v1/pdf/template \ -H "Authorization: Bearer <your API key>" \ -H "Content-Type: application/json" \ -H "Prefer: respond-async" \ -d '{"templateId": "<your template id>", "modify": {"customer": "Acme Inc."}}'

Once the render is queued, oJo answers 202 Accepted with the headers Preference-Applied: respond-async, Location: /v1/renders/{id} and Retry-After: 2, and this body:

{ "id": "6f1c2a0e-3b7d-4c55-9a1e-0d2f8b9c7e41", "status": "pending", "createdAt": "2026-10-28T12:00:00.000Z" }

A request that cannot start gets the same answer it gets without the header, such as 402 when your credits run short or 422 for a missing template.

Check the status

Send GET /v1/renders/{id} with your API key, the address from the Location header. While the status is pending, wait the seconds in Retry-After and check again.

curl https://api.ojo.so/v1/renders/6f1c2a0e-3b7d-4c55-9a1e-0d2f8b9c7e41 \ -H "Authorization: Bearer <your API key>"

The status is one of:

  • pending - The render is still running.
  • succeeded - result holds the answer the request gives without the header, and statusCode is 200.
  • failed - error holds the error answer the request gives without the header, and statusCode its HTTP status, such as 422 for a page that could not be rendered or 504 for a render that took too long. A failed render spends no credits.
{ "id": "6f1c2a0e-3b7d-4c55-9a1e-0d2f8b9c7e41", "status": "succeeded", "createdAt": "2026-10-28T12:00:00.000Z", "completedAt": "2026-10-28T12:00:01.200Z", "statusCode": 200, "result": { "id": "a1b2c3", "pdfUrl": "https://cdn.ojo.so/a1b2c3.pdf", "pages": 1, "credits": 1, "createdAt": "2026-10-28T12:00:01.100Z" } }

If the render is lost before it finishes, for example while oJo restarts, the status turns failed within 15 minutes with statusCode 503 and error.code render_lost, and you send the request again. A lost render spends no credits.

How long a status is kept

oJo keeps a status for 24 hours. After 24 hours GET /v1/renders/{id} answers 404. The rendered image or PDF stays in your account until you delete it, listed under GET /v1/image or GET /v1/pdf.

Status checks have their own rate limit of 300 per minute, so checking never uses up your render limit.

With an Idempotency-Key

An async request takes an Idempotency-Key too, see Safe retries. While its render runs, the same request with the same key answers 409. Once it finished, the same request gets the stored answer of the finished render.

Webhook

Instead of checking the status, you can have oJo send it to you. Set a webhook URL on the dashboard, under API. The URL must use https on port 443 and reach a public address. oJo then sends one event to that URL for each async request of your account once its status is succeeded or failed, a lost render included. Requests without Prefer: respond-async send no event.

The event is a POST with a JSON body. data is the status you would get from GET /v1/renders/{id}:

{ "type": "render.succeeded", "timestamp": "2026-10-28T12:00:01.300Z", "data": { "id": "6f1c2a0e-3b7d-4c55-9a1e-0d2f8b9c7e41", "status": "succeeded", "createdAt": "2026-10-28T12:00:00.000Z", "completedAt": "2026-10-28T12:00:01.200Z", "statusCode": 200, "result": { "id": "a1b2c3", "pdfUrl": "https://cdn.ojo.so/a1b2c3.pdf", "pages": 1, "credits": 1 } } }

type is render.succeeded or render.failed.

Verify the signature

Events are signed as Standard Webhooks  define, with three headers:

  • webhook-id - the status id, the same on every attempt. Use it to drop an event you already handled.
  • webhook-timestamp - when this attempt was sent, in Unix seconds.
  • webhook-signature - v1, and the signature of this attempt.

The signing secret starts with whsec_. The dashboard shows it, and rotating it replaces the old one at once. Verify each event with a Standard Webhooks library against the raw request body, before you parse it:

import { Webhook } from 'standardwebhooks'; const webhook = new Webhook(process.env.OJO_WEBHOOK_SECRET); // Throws when the signature does not match or the timestamp is older than 5 minutes. const event = webhook.verify(rawBody, { 'webhook-id': request.headers['webhook-id'], 'webhook-timestamp': request.headers['webhook-timestamp'], 'webhook-signature': request.headers['webhook-signature'], });

Libraries for Python, Go, Ruby, PHP, Java and more are listed at standardwebhooks.com .

Retries

Answer with a 2xx status within 15 seconds. Any other answer, a redirect included, counts as a failure, and oJo tries again 5 s, 5 min, 30 min and 2 h after the first attempt, 5 attempts in all. A 410 answer stops the retries for that event. oJo reads at most 64 KB of your answer and ignores it.

An event can still be missed, for example when your endpoint is down for longer than 2 hours. GET /v1/renders/{id} stays the source of truth for 24 hours, so check it when an event does not arrive.