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.
Synchronous: POST /v1/render
Section titled “Synchronous: POST /v1/render”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.pdfA render usually takes a second or two. Give your HTTP client a timeout of at least 60 seconds for large documents.
Asynchronous: render jobs
Section titled “Asynchronous: render jobs”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"]}Getting the result
Section titled “Getting the result”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());import osimport time
import requests
api = "https://api.doquill.com"headers = {"Authorization": f"Bearer {os.environ['DOQUILL_API_KEY']}"}
created = requests.post( f"{api}/v1/render/jobs", headers=headers, json={"template_id": "<template-id>", "data": {"number": "INV-1042"}}, timeout=30,)created.raise_for_status()job = created.json()["data"]
while job["status"] == "pending": time.sleep(2) job = requests.get(f"{api}/v1/render/jobs/{job['id']}", headers=headers, timeout=30).json()["data"]if job["status"] == "failed": raise RuntimeError(job["error"])
pdf = requests.get(f"{api}/v1/files/{job['file_id']}/content", headers=headers, timeout=60).contenttype job struct { ID string `json:"id"` Status string `json:"status"` FileID string `json:"file_id"` Error string `json:"error"`}
func renderAsync(ctx context.Context, apiKey string, body []byte) ([]byte, error) { do := func(method, path string, body io.Reader) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, "https://api.doquill.com"+path, body) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") return http.DefaultClient.Do(req) } decode := func(resp *http.Response) (job, error) { defer resp.Body.Close() var out struct{ Data job } err := json.NewDecoder(resp.Body).Decode(&out) return out.Data, err }
resp, err := do(http.MethodPost, "/v1/render/jobs", bytes.NewReader(body)) if err != nil { return nil, err } if resp.StatusCode != http.StatusAccepted { msg, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, fmt.Errorf("create job: %s", msg) } j, err := decode(resp) for err == nil && j.Status == "pending" { time.Sleep(2 * time.Second) if resp, err = do(http.MethodGet, "/v1/render/jobs/"+j.ID, nil); err == nil { j, err = decode(resp) } } if err != nil { return nil, err } if j.Status == "failed" { return nil, errors.New(j.Error) }
resp, err = do(http.MethodGet, "/v1/files/"+j.FileID+"/content", nil) if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body)}Retries
Section titled “Retries”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.
Stored files
Section titled “Stored files”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.