Generate documents from your own system
This is for a developer wiring Papers into an application: your system sends the data, Papers renders one of your templates and hands back a file. Plain HTTPS and JSON, no SDK.
The full endpoint reference, with every field and response, is at papers.ebizi.co.za/docs. It covers templates, signing requests, webhooks and the rest of the API as well as generation. This is the walkthrough for generation.
Before you start
You need a published template and an API key. Publishing is versions and publishing; the key comes from environments and API keys.
Your key belongs to one environment and generates only from templates published to that environment. Nothing in a request can change that, so a staging key cannot produce a live document.
Two headers on every request
X-Api-Key: tf_<environment>_<secret>
X-Company-Id: <your company GUID>
Both come from Settings, API Keys. Send either one alone and the request comes back 401
without saying which of the two was wrong.
The base URL is https://papers.ebizi.co.za and every path below sits under /v1.
Generate a document
curl -X POST https://papers.ebizi.co.za/v1/generate \
-H "X-Api-Key: $PAPERS_API_KEY" \
-H "X-Company-Id: $COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"templateId": "019fb83e-5fb1-7515-8408-0d52117b5596",
"outputFormat": "pdf",
"data": "{\"employee\":{\"fullName\":\"Zanele Mokoena\"},\"salary\":{\"monthly\":24500}}"
}'
data is a string holding your JSON, not a JSON object. That trips everybody up once: send an
object and you get a 400 saying the value could not be converted to a string. Serialise your
payload, then put the result in the field.
Identify the template by templateId or by templateSlug, exactly one of the two. The slug is
stable across companies, which is what you want if you run the same integration for several of
them.
You get 202 Accepted with the job, and a Location header pointing at it:
{
"jobId": "019fc6cf-9dbb-7b98-ae3f-308ee0e19281",
"status": "completed",
"downloadUrl": "/v1/generation-jobs/019fc6cf-9dbb-7b98-ae3f-308ee0e19281/download",
"errorMessage": null,
"durationMs": 840,
"createdAt": "2026-10-22T09:14:03Z"
}
If the template or the data cannot be rendered, the request itself fails with an error instead (see handle the errors), and the job is recorded as failed.
Check the job, then download
When status in the response is completed, the file is ready: go straight to downloadUrl. If it
says anything else, check the job every second or two until it is completed or failed:
curl https://papers.ebizi.co.za/v1/generation-jobs/$JOB_ID \
-H "X-Api-Key: $PAPERS_API_KEY" -H "X-Company-Id: $COMPANY_ID"
Then download it:
curl -L https://papers.ebizi.co.za/v1/generation-jobs/$JOB_ID/download \
-H "X-Api-Key: $PAPERS_API_KEY" -H "X-Company-Id: $COMPANY_ID" \
-o contract.pdf
The response body is the file. Stream it to disk rather than holding it in memory.
Shape the data the way the template expects
The keys in data are the variable paths the template uses. A key with dots in it is read as a
path, so these are the same payload:
{ "employee.fullName": "Zanele Mokoena" }
{ "employee": { "fullName": "Zanele Mokoena" } }
Sending a value and a property of that value for the same key is refused with a 400 naming the
key: company as a string and company.name together cannot both be true.
A repeating section needs an array of objects under the name the template repeats over. A data table needs an array of arrays. Get these from whoever built the template, or read them off its sample data.
Pick which version renders
By default you get the version published to your key's environment.
versionNumber pins a specific published version, which is how you keep a document reproducible
after the template moves on. useDraft: true renders the unpublished draft instead, which is what
the editor's own Download does. It works for single documents only, not batches. Keep
useDraft out of anything your customers see.
Generate a batch
POST /v1/generate/batch takes one template and an items array, up to 500 of them, and gives
you one batch job.
-d '{ "templateSlug": "payslip", "outputFormat": "pdf",
"items": [
{ "data": "{...}", "metadata": { "fileName": "Payslip Zanele Mokoena" } },
{ "data": "{...}", "password": "..." }
] }'
A batch is queued rather than rendered while you wait. You get 202 Accepted with batchJobId
(not jobId), status of queued, totalItems and createdAt.
Each item can carry metadata, a set of text values saved with the item and returned with its
results. metadata.fileName names that item's file in the ZIP. An item can also carry its own
password, which overrides a password set for the whole batch.
GET /v1/generation-jobs/{batchJobId} gives the batch's progress: total, completed, failed and
pending item counts. Per-row results are at GET /v1/generation-jobs/{batchJobId}/items, a page at
a time, and the batch's download is a ZIP of what succeeded. A batch can finish as
completed_with_errors, so check the items rather than trusting the batch.
A big batch takes a while. Rather than polling it for minutes, register a webhook and let Papers tell you as items finish: see webhooks.
Email each document in a batch
Add email to a batch request and Papers emails every document to its own recipient when the batch
finishes:
-d '{ "templateSlug": "payslip", "outputFormat": "pdf",
"email": { "subject": "Your payslip for {{Month}}",
"body": "<p>Hi {{FirstName}}, your payslip is attached.</p>" },
"items": [ { "data": "{...}",
"metadata": { "email": "zanele@example.co.za", "name": "Zanele Mokoena",
"field:Month": "October 2026", "field:FirstName": "Zanele" } } ] }'
Every item needs metadata.email, or the request is refused with a 400. metadata.name is the
name the email is addressed to. A {{Column}} token in the subject or the body is filled from that
item's field:Column metadata value.
The subject is plain text, up to 200 characters. The body is HTML, up to 5,000 characters, and only basic formatting and links are kept. The document goes as an attachment, or as a download link that lasts seven days when the file is too big to attach.
If some emails were not sent, POST /v1/generation-jobs/{batchJobId}/email tries again. Items that
were already emailed are skipped, so only the rest go out. An empty body reuses the batch's saved email,
or send a new subject and body.
List your jobs
GET /v1/generation-jobs lists your generation jobs a page at a time. Use page and pageSize
(20 by default), and filter with status and templateId:
curl "https://papers.ebizi.co.za/v1/generation-jobs?status=failed&pageSize=50" \
-H "X-Api-Key: $PAPERS_API_KEY" -H "X-Company-Id: $COMPANY_ID"
The response has items, totalCount, page, pageSize and hasMore. Keep asking for the next
page while hasMore is true.
Handle the errors
Errors are RFC 7807 problem details, with Content-Type: application/problem+json. Read detail.
When a request fails Papers' checks, detail lists every problem, separated by semicolons. Only a
body that cannot be read at all, such as an object where a string belongs, also carries errors.
400: something in your request to fix, such as a missing field or data that is not valid JSON.401: a setup problem with the key or the company id, not a blip.402: the company's subscription needs attention. This body is not a problem detail: it haserrorand asubscribeUrl.404: the template, the version you pinned, or a version published to your key's environment could not be found.409: you asked too early, such as downloading a job that has not finished.422: the request makes sense but cannot be done, such asuseDrafton a template with no saved draft.500: can come from the template itself, such as an expression it cannot work out or a fragment or image it cannot find. Readdetailbefore you retry, because those fail the same way every time.
A single document that cannot be rendered fails the request, as above. A batch is different:
POST /v1/generate/batch returns 202 before anything renders, so an item that fails shows up as an
item whose status is failed, with the reason in errorMessage. Sending the same data again gives
the same failure.
Test against a real template first
Generate one document by hand with curl before you write any code, and open the file. Half the
integration problems are the shape of data, and a document with three blank spaces in it tells
you more than any error message will.