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:

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
WhatMeasured, 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

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.