The ticket says "Add PDF export."
Four words. Very calm words.
You build an invoice. Three line items. One page. The logo is where logos traditionally live.
Then someone adds a longer company name, 87 line items, and a payment note.
The total lands on page three. The footer starts a solo career.
The file still downloads successfully. Apparently, everything is fine.
A successful render and a usable document are two different acceptance criteria.
I'm building Bitavu, a visual template editor with a JSON-to-PDF API. This is the design problem behind it: how do you let a document evolve without making every layout adjustment another application change?
The useful answer starts with how you test and release the template.
The first PDF is the easy one
A demo invoice is an extremely cooperative customer.
It has a short name. Its address fits. Its three descriptions are roughly the same length. It would never order enough things to inconvenience your page margins.
Real documents have less interest in your demo.
A request like "make the description column wider" can change row heights. Those heights affect page breaks. A page break can separate a total from the lines that explain it.
That is how a small layout change earns a surprisingly large review.
Treating the template as something you can test and release gives you a way to handle that review deliberately.
Give the layout its own home
There are two different jobs inside an invoice.
Your application knows what happened: who bought what, which amounts apply, and when payment is due.
The template decides how to present it: columns, spacing, branding, repeated headings, and where content continues onto another page.
A useful boundary looks like this:
Application data + tested template revision → PDF
That boundary can live entirely in your own codebase. You do not need a separate service to adopt it.
What matters is being able to change the layout while keeping its expected input clear.
In Bitavu Studio, that separation is expressed through visual document blocks, JSON bindings, and saved template revisions. A repeating table can take its rows from an items array; the backend supplies the data.
The visual editor gives layout work a place to happen. You still need to review the result.
Give your invoice some deeply inconvenient data
Before changing the design, save a small set of synthetic fixtures.
Start with a tidy invoice so you can spot obvious mistakes. Then make the data less polite.
| Fixture | What to check |
|---|---|
| One item, then enough items to span several pages | Table continuation, repeated headings, and footer placement |
| The last row count that fits, then one more row | The exact transition onto the next page |
| A long company name and multi-line address | Wrapping and the space left above the table |
| One very long item description | Row splitting and whether its quantity and amount remain easy to associate |
| Missing optional notes or address lines | Intended fallbacks and unwanted blank space |
| Names with accents and symbols used by your customers | Font coverage, shaping, and alignment |
The "one more row" case deserves special attention.
If 18 rows fit and 19 spill over, keep both fixtures. Those numbers belong to that particular template, font, and data; they are not a universal page capacity.
Also try the same row count with longer descriptions. Counting rows alone will not tell you how tall a table becomes.
The goal is to find the point where the layout changes behavior, then keep a test on either side of it.
Your least cooperative fake customer is doing valuable work here.
Make the backend request boring
Once the template has a clear input shape, the integration should be straightforward.
Here is a synthetic Bitavu invoice request. Save it as request.json:
{ "templateId": "invoice", "revision": 3, "data": { "invoice": { "number": "INV-2027-014", "date": "2027-01-12", "due": "2027-02-11" }, "client": { "name": "Bitavu Studio", "address": "74372 Washington Ave\nColumbus, 7206" }, "items": [ {"description": "Brand strategy workshop", "quantity": 1, "unitPrice": 650}, {"description": "Design system", "quantity": 3, "unitPrice": 280}, {"description": "Project support", "quantity": 2, "unitPrice": 95} ], "notes": "Thank you for working with us." } } invoice and revision 3 identify the example. Use a saved template ID and revision from your own workspace, with data that matches its bindings.
With your scoped API key in BITAVU_API_KEY, this Bash example creates a key for one render operation and sends the request:
# Create once for this operation. Reuse it for this operation's retries. BITAVU_RENDER_KEY="$(node -e 'console.log(require("node:crypto").randomUUID())')" curl --fail-with-body --retry 2 --retry-delay 2 \ https://api.bitavu.com/v1/render \ -H "Authorization: Bearer $BITAVU_API_KEY" \ -H "Idempotency-Key: $BITAVU_RENDER_KEY" \ -H "Content-Type: application/json" \ --data @request.json \ --output invoice.pdf The successful response is the PDF itself.
Keep the API key on your server. For a new operation, generate a new idempotency key. For a retry of the same operation, preserve the key, request body, and template revision.
If you retry later, do not rerun the key-generation line and accidentally turn the retry into a new operation.
A saved design should not surprise production
Suppose production requests revision 3.
You adjust the column widths and save a new revision. Production should keep using 3 while you review the change.
A practical release sequence is:
- Render the saved fixtures against the candidate revision.
- Check the resulting pages, especially the page-boundary cases.
- Approve the layout and confirm that the application's data still matches.
- Update the revision requested by production.
A revision pin controls which template you request. It is not a promise that every byte of every PDF will be identical: renderer changes, fonts, and referenced assets also matter.
For the same reason, a PDF binary diff is a poor substitute for checking the document. Metadata can change without changing the page; a misplaced total can matter enormously even though the file opens.
If a field changes from client.name to customer.legalName, treat that as an integration change too. Moving the layout into an editor does not make the data contract disappear.
When a visual editor earns its place
For one fixed document that rarely changes, a small rendering function in your application may be all you need.
The balance shifts when layouts keep changing, several templates share branding, or someone needs to adjust the document without editing application code.
That is the workflow I'm building Bitavu for: a visual place to maintain the template, with a versioned API for the application that uses it.
You still choose the data contract. You still test the awkward cases. You get a clearer place to make and review the layout changes.
The paper monster gets a new job.
What is the most uncooperative thing you have ever had to fit on an invoice?
