Excel report API
Somebody in finance maintains a monthly report as an .xlsx. It has a
header block, a table that grows, a total row with real formulas, and a second sheet. You have the
numbers as JSON and you want that workbook back, filled, on a schedule. This page is about the
three things that decide whether the file you get back is a report or just a spreadsheet
with the right text in it. Every number below was measured against the live API from Germany on
6 September 2026, with a free key, using the sales report in
examples/.
The job, and the three ways it goes wrong
A .docx is a stream of text; filling one is mostly a matter of finding placeholders
that Word has split across runs. A workbook is a different animal, and each of its three
peculiarities is a place where a naive filler produces a file that opens fine and is wrong.
Nobody gets an error. The recipient gets a report whose total says something else than the rows
above it.
1. The shared string table leaks your template back to the reader
Excel does not store cell text in the cell. It stores an index into
xl/sharedStrings.xml, and two cells with the same text share one entry. That has a
consequence most fillers never notice: after the render, the old entry is still in the
package. Nothing points at it any more, Excel ignores it, and it sits there holding the template.
Run this on any delivered workbook, yours or ours:
unzip -p report.xlsx xl/sharedStrings.xml | grep -o '{[a-z#/][^}]*}'
On the template we sent, that prints the whole placeholder vocabulary:
{company}
{period|date:MMMM YYYY}
{generated|date:DD MMMM YYYY}
{source}
{currency}
{#rows}
{region}
{rep}
...
On the workbook that came back, the same command prints nothing — zero matches. The last
thing a render does is blank every shared-string entry that still holds template text and that no
cell points at any more. This matters where a document leaves your building: a DLP scan, an
eDiscovery export, or simply a customer who opens the file with something other than Excel reads
whatever is in that part. A leftover {internal_margin} is a small thing until it is in
a file you sent a client.
2. A number that is text makes SUM() return 0
A cell containing the characters 1234.5 is not the number 1234.5. Sum a column of
those and Excel answers 0, the chart is empty, and nothing warns anybody. This is the single
biggest difference between a spreadsheet filler that is useful and one that is not.
So a cell whose entire content is one placeholder that resolves to a number becomes a real
numeric cell, and keeps its s= style, so the number format, font, border and fill the
author chose all survive. From the workbook measured today,
unzip -p sales-out.xlsx xl/worksheets/sheet1.xml:
<c r="A10" s="6" t="inlineStr"><is><t>DACH</t>... <- text stays text
<c r="D10" s="7"><v>2140</v></c> <- no t=, so a number
<c r="E10" s="8"><v>59706</v></c> <- style 8 kept: € format survives
The rules, in the order they are applied to a cell that is exactly one placeholder:
- a JSON number, or an arithmetic formatter (
sum,count,round,multiply, …) → numeric cell; - a JSON boolean →
t="b"; - a date, or an ISO date string, in a cell that already carries a date number format → the Excel serial, so the cell sorts, charts and reformats like a date;
- anything else, including a numeric string, → text.
That last rule is deliberate and occasionally annoying. "30" in your JSON stays
text, because coercing strings would turn the part number "007" into 7 and the German
postcode "01067" into 1067. If you want it coerced, ask:
{n|add:0}. And to get a real number that still displays as money, format the
cell in the template and write a bare {total} — the cell's own number format does the
display work and SUM() over the column still adds up. A formatter like
currency produces text on purpose, because you asked for that exact string.
3. Rows move, and formulas have to move with them
This is the one that separates a filler from a report generator, and it is easy to check on any product you are evaluating. Our template's loop row and total row look like this:
row 6 (the loop body) G6: IF(F6=0,"",E6/F6-1) H6: E6/E7
row 7 (the total) D7: SUM(D6:D6) H7: SUM(H6:H6)
Note H6: E6/E7 — each row's share of the total, pointing at the total row directly
below it. Twelve data rows later, the total row is no longer row 7, it is row 18. Here is the
workbook that came back today:
G6: IF(F6=0,"",E6/F6-1) H6: E6/E18
G7: IF(F7=0,"",E7/F7-1) H7: E7/E18
...
G15: IF(F15=0,"",E15/F15-1) H15: E15/E18
row 18 (the total) D18: SUM(D6:D17) E18: SUM(E6:E17) H18: SUM(H6:H17)
Two different things happened, and both had to. Inside a repeated row, a reference to a cell of
the same row moved with the copy (F6 became F7, F8, …). A
reference to a row outside the loop followed that row down (E7 became
E18 in every copy, not E19, E20, …). And the total
row's ranges widened over the whole expanded body. Those are Excel's insert-rows semantics rather
than copy-paste semantics, which is what a human doing this by hand would get.
The same rewriting moves merged ranges, the sheet dimension, conditional formatting, data validation, hyperlinks, autofilter and defined names, because leaving an autofilter behind after inserting rows is a file Excel opens with a repair prompt.
The known limit, said before you rely on it: a range whose two ends are the same repeated
row — SUM(B3:B3) over a per-group subtotal row — widens to first..last occurrence and
therefore covers the rows in between too. Write a grand total as a sum of the detail rows rather
than a sum of the subtotals. And chart series and pivot caches keep their own private copies of the
ranges they read; those are not rewritten.
The call, measured today
curl -X POST https://docmint.app.mintapis.com/v1/render \
-H "Authorization: Bearer $DOCMINT_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_base64": "<your .xlsx, base64>",
"data": { "company": "Meridian Field Systems B.V.",
"period": "2026-07-01",
"rows": [ { "region": "Benelux", "units": 1240, "revenue": 52700 }, ... ] },
"output": "document"
}' -o report.xlsx
| What | Measured, 6 September 2026 |
|---|---|
output: "document" | 93.6 ms server-side, 176 ms round trip from Germany. 25 tags, 112 substitutions, 2 sections, 4 parts rewritten. 8,109 B template → 9,484 B workbook. 1 credit. |
output: "pdf" | 1,462.2 ms server-side — of which 1,426.7 ms is LibreOffice and 15.8 ms is the fill. 2 pages, 36,037 B. 2 credits. |
Read that second row honestly: the PDF step is 99% of the time and it is not our code. It runs
one conversion at a time, so a burst queues rather than slows. If you want the workbook, ask
for the workbook; it is twenty times faster and half the price. The PDF text layer of the run above
reads back with pdftotext as
Meridian Field Systems B.V. / Sales by region and channel — July 2026 / … Page 1 of 2,
so the header and footer the template author set survive the conversion.
Both sheets are handled: the run above touched
xl/sharedStrings.xml, xl/workbook.xml,
xl/worksheets/sheet1.xml and xl/worksheets/sheet2.xml in one call.
When it goes wrong, it names the cell
A spreadsheet error that says "missing field" and nothing else means opening the template and hunting. Three deliberate failures, run today against the live API:
{"code": "placeholder_unresolved",
"message": "The template uses {company} but the data has no \"company\".",
"hint": "Add \"company\" to the data, or write {company|default:} to allow it to be absent.",
"details": {"field": "company", "location": "Monthly sales!A1",
"available": ["period","generated","currency","source","rows","regions"],
"format": "xlsx"}}
location is the sheet name and the cell. The second failure sent
"ouput" instead of "output" and got back
There is no field called "ouput" - did you mean "output"? with the full list of accepted
fields. The third sent "rows": "value" — a string where the template expects a list —
and got The template uses {region} but the data has no "region", pointing at
Monthly sales!A6, with Did you mean "regions"? A section that is present but is
not a list does not quietly render an empty table.
None of those three cost anything. GET /v1/usage read
{"used": 6, "limit": 30, "remaining": 24} before them and
{"used": 6, …} after. Credits are taken before the work and refunded when the work
fails, so a run that errors on a missing field is free.
What this does not do
- No chart or pivot rewriting. A chart whose series points at the loop range keeps the range it was saved with. If your report needs a chart over a table that grows, point the series at a whole column or a defined name that covers more rows than you will ever have.
- No column loops and no sheet loops. A section whose two tags sit in one row is treated as a row loop, because repeating part of a row would leave the rest of the line behind. One sheet per template, repeated rows within it.
- No new formatting. Everything visual comes from the template — this is a filler, not a spreadsheet builder. There is no browser template editor, and none is planned.
- Nothing is evaluated. Formulas are rewritten as text and computed by Excel or LibreOffice when the file is opened; no expression in your data is ever compiled or executed. That is the whole reason this service carries no sandbox with published escape CVEs.
What we cannot claim
DocMint is new and small. No paying outside customer yet, no uptime history worth
quoting, no SLA, no SOC 2, one region. The free plan is 30 credits from a single
POST /v1/signup with no card — that is what this page was measured with, and 30
credits is small on purpose. The PDF step is a single LibreOffice process; if your month-end is
five hundred workbooks converted to PDF in one minute, we are not that today, and
POST /v1/render/batch is the honest answer for volume rather
than a claim about speed.
What is checkable from outside, and was checked today: the numbers in the table above, the zero-match shared-string scrub, the rewritten formulas, and the three refused calls that cost nothing.
Next: the same engine on a deck — PowerPoint generation API — or the wider comparison on the Word template API page.