Skip to content
Get started

Sync vs async rendering

There are two ways to render:

POST /v1/render POST /v1/render/jobs
Returns The document bytes A job ID, immediately
Stores a file No Yes
Retries temporary failures No, you retry Yes, automatically
Webhooks No render.succeeded / render.failed
Use for Downloads a user is waiting for Batches, emails, archiving, anything in the background

Both take the same template_id, template_version, data, filename and metadata fields, validate data against the template’s schema before doing anything else, and count against the same quota.

The response body is the document. Choose the format with the Accept header (PDF by default, see Output formats). filename only sets the Content-Disposition header.

curl https://api.doquill.com/v1/render \
-H "Authorization: Bearer $DOQUILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"template_id": "<template-id>", "data": {"number": "INV-1042"}, "filename": "INV-1042.pdf"}' \
-o INV-1042.pdf

A render usually takes a second or two. Give your HTTP client a timeout of at least 60 seconds for large documents.

POST /v1/render/jobs validates the request, queues it and answers 202 Accepted with the job ID and a Location header pointing at the job:

{ "data": { "id": "0b6c…", "status": "pending" } }

The job request takes two extra fields:

Field
format pdf (default), png, jpeg, html or docx
tags Up to 10 strings to label the stored file, such as a customer or batch ID. Each is at most 128 characters of letters, digits, spaces and +-=._:/@.
{
"template_id": "<template-id>",
"data": { "number": "INV-1042" },
"filename": "INV-1042.pdf",
"tags": ["customer:acme", "batch:2026-10"]
}

Either subscribe a webhook to render.succeeded and render.failed, or poll GET /v1/render/jobs/{id} until status is no longer pending:

{ "data": { "id": "0b6c…", "status": "ready", "file_id": "5f1e…" } }
{ "data": { "id": "0b6c…", "status": "failed", "error": "render failed: …" } }

The 202 response carries Retry-After: 2; polling every couple of seconds is plenty. Then download the file with GET /v1/files/{file_id}/content.

const headers = { Authorization: `Bearer ${process.env.DOQUILL_API_KEY}` };
const created = await fetch("https://api.doquill.com/v1/render/jobs", {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ template_id: "<template-id>", data: { number: "INV-1042" } }),
});
if (!created.ok) throw new Error(await created.text());
let { data: job } = await created.json();
while (job.status === "pending") {
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = await fetch(`https://api.doquill.com/v1/render/jobs/${job.id}`, { headers });
({ data: job } = await res.json());
}
if (job.status === "failed") throw new Error(job.error);
const file = await fetch(`https://api.doquill.com/v1/files/${job.file_id}/content`, { headers });
const pdf = Buffer.from(await file.arrayBuffer());

A job that fails for a temporary reason, such as the renderer being briefly unavailable, is retried automatically with backoff, up to 8 attempts. A job that fails because of the template or data, such as a template error, fails right away. Either way the job ends as failed with an error message and a render.failed webhook.

Every successful job stores a file in the workspace. Files are kept until you delete them.

Endpoint
GET /v1/files List files, newest first. Filter with template-id=<id> and tag=<tag> (repeat tag to match files with any of the tags).
GET /v1/files/{id} File metadata: filename, content type, size, tags, template
GET /v1/files/{id}/content Download the file
DELETE /v1/files/{id} Delete the file

The app’s Files page shows the same files.