GET /v1/renders/:id
Poll a render until it succeeds, fails, or times out.
Get the current status of a render. Most renders complete in under 200ms — this endpoint is what you poll between enqueue and download. For high volume, prefer the outgoing webhook.
GET /v1/renders/:idQuick Start
curl
curl https://api.craftkit.dev/v1/renders/0193c2c3 \
-H "Authorization: Bearer $CRAFTKIT_API_KEY"Node.js
async function pollUntilDone(pollUrl) {
const headers = { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` };
let delay = 200;
for (let i = 0; i < 20; i++) {
const job = await (await fetch(pollUrl, { headers })).json();
if (job.status === 'succeeded' || job.status === 'failed') return job;
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 1000);
}
throw new Error('render timed out');
}Python
import os, time, requests
def poll_until_done(poll_url):
headers = {"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"}
delay = 0.2
for _ in range(20):
job = requests.get(poll_url, headers=headers).json()
if job["status"] in ("succeeded", "failed"):
return job
time.sleep(delay)
delay = min(delay * 1.5, 1.0)
raise TimeoutError("render timed out")Response
{
"id": "0193c2c3-...",
"status": "succeeded",
"pollUrl": "https://api.craftkit.dev/v1/renders/0193c2c3-...",
"downloadUrl": "https://cdn.craftkit.dev/.../0193c2c3.pdf",
"errorMessage": null,
"createdAt": "2026-05-03T10:14:00.000Z",
"completedAt": "2026-05-03T10:14:00.150Z",
"durationMs": 150
}| Field | Type | Description |
|---|---|---|
id |
string | Render id (UUIDv7). |
status |
string | See status progression below. |
pollUrl |
string | Self-referential; same as the request URL. |
downloadUrl |
string | null | Populated when status === 'succeeded'. A permanent public URL (no presigning, no TTL); null if no public bucket is configured. For private or revokable delivery, use the partner-key download stream or a share link. |
errorMessage |
string | null | Populated when status === 'failed'. |
createdAt |
string | ISO-8601 enqueue timestamp. |
completedAt |
string | null | ISO-8601 terminal-state timestamp. |
durationMs |
integer | null | Wall-clock render duration. |
Status progression
queued → rendering → succeeded
→ failed
→ cancelledsucceeded and failed are the terminal states you will observe — once reached, the row never changes. cancelled exists in the enum but is not produced today (there is no cancellation path), so branch on succeeded/failed.
Polling cadence
A reasonable poll loop:
| Step | Delay |
|---|---|
| First poll | 200ms after the 202 |
| Backoff | 200ms, 300ms, 500ms, 1s, 1s, 1s ... capped at 1s |
| Timeout | 30s — return an error to your caller after that |
There is no synchronous render mode — options.sync is accepted by the schema but not honored, and the render endpoint always returns 202 with status: "queued". Either poll (above) or use the outgoing webhook (below). If you'd rather not poll, prefer the webhook.
Outgoing webhook (preferred for high volume)
Configure an outgoing webhook in your project settings. Craftkit POSTs to your URL when a render reaches a terminal state, signed with HMAC. Saves the polling round-trip and your worker count.
{
"event": "render.succeeded",
"renderId": "0193c2c3-...",
"templateId": "1f2e3d4c-...",
"status": "succeeded",
"downloadUrl": "https://cdn.craftkit.dev/.../0193c2c3.pdf",
"errorMessage": null,
"createdAt": "2026-05-03T10:14:00.000Z",
"completedAt": "2026-05-03T10:14:00.150Z"
}Headers include x-craftkit-event, x-craftkit-signature (HMAC-SHA256 hex of the raw body), x-craftkit-timestamp, and x-craftkit-delivery-id. Subscribed events are render.succeeded and render.failed.
Verify the signature with your webhook secret:
Node.js
import crypto from 'node:crypto';
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
if (expected !== req.headers['x-craftkit-signature']) {
throw new Error('bad signature');
}Python
import hmac, hashlib, os
expected = hmac.new(
os.environ["WEBHOOK_SECRET"].encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, request.headers["x-craftkit-signature"]):
raise ValueError("bad signature")Best practices
- Always cap your poll loop. A runaway poll loop on a stuck render burns API budget. 20 attempts × 1s max = 20s is plenty.
- Use the outgoing webhook above ~10 renders/min. Polling at scale wastes round-trips.
downloadUrlis a permanent public URL (no presigning, no TTL). If you need expiry or private delivery, mint a share link or use the partner-key download stream rather than handing out the raw URL.- Check
durationMsfor capacity planning. Sustained durations > 1s mean your templates are doing heavy work — consider splitting them.
Related
- POST /v1/templates/:slug/render — produces the id you poll
- Errors — what
status: 'failed'looks like - Inbound webhook — third-party trigger, same status flow