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/htmlPOST /v1/image/templatePOST /v1/image/bulkPOST /v1/pdf/htmlPOST /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-resultholds the answer the request gives without the header, andstatusCodeis200.failed-errorholds the error answer the request gives without the header, andstatusCodeits HTTP status, such as422for a page that could not be rendered or504for 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.