Documentation
Ship Excel export without owning a spreadsheet library.
POST JSON, get a styled .xlsx back. Ten rows or ten million.
This page is the whole API. There is not much of it, which is the point.
Quickstart
Create a key in the dashboard, then:
curl -X POST https://api.jarrah.sh/v1/workbooks \
-H "Authorization: Bearer $JARRAH_KEY" \
-H "Content-Type: application/json" \
-d '{"sheets":[{"name":"Sales",
"columns":[{"header":"Item"},{"header":"Amount"}],
"rows":[["Widget",1250.5],["Gadget",99]]}]}' \
-o sales.xlsx
import requests
r = requests.post(
"https://api.jarrah.sh/v1/workbooks",
headers={"Authorization": f"Bearer {key}"},
json={"sheets": [{
"name": "Sales",
"columns": [{"header": "Item"}, {"header": "Amount"}],
"rows": [[o.name, o.amount] for o in orders],
}]},
)
r.raise_for_status()
open("sales.xlsx", "wb").write(r.content)
const res = await fetch("https://api.jarrah.sh/v1/workbooks", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
sheets: [{
name: "Sales",
columns: [{ header: "Item" }, { header: "Amount" }],
rows: orders.map((o) => [o.name, o.amount]),
}],
}),
});
if (!res.ok) throw new Error(await res.text());
await writeFile("sales.xlsx", Buffer.from(await res.arrayBuffer()));
body, _ := json.Marshal(map[string]any{
"sheets": []any{map[string]any{
"name": "Sales",
"columns": []any{map[string]string{"header": "Item"}},
"rows": rows,
}},
})
req, _ := http.NewRequest("POST", "https://api.jarrah.sh/v1/workbooks", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
// res.Body is the .xlsx
res = Net::HTTP.post(
URI("https://api.jarrah.sh/v1/workbooks"),
{sheets: [{name: "Sales", rows: rows}]}.to_json,
"Authorization" => "Bearer #{key}",
"Content-Type" => "application/json",
)
File.binwrite("sales.xlsx", res.body)
That is the entire happy path. Everything below is refinement.
Sync or async
Two endpoints, one payload shape.
POST /v1/workbooks | POST /v1/jobs | |
|---|---|---|
| Returns | The file | 202 and a job id |
| Good for | Under ~50k cells | Anything larger |
| Body limit | 25 MB | 250 MB |
| Streaming input | No | Yes, NDJSON |
Send everything to /v1/workbooks until it tells you not to. Past your
plan's cell limit it returns 413 naming /v1/jobs, so you do not
have to guess where the line is.
Cells and types
A cell is a bare JSON value, or an object when it needs to be more.
["Widget", 1250.5, true, null]
[{"v": 1250.5, "s": "money"},
{"f": "=SUM(B2:B10)", "result": 4820},
{"v": "2026-08-06", "t": "date"},
{"url": "https://example.com", "text": "invoice"}]
= stays a string. "2026-08-06" stays text
until you tag it "t": "date". Guessing is how spreadsheets turn part
numbers into decimals and gene names into dates. One extra key removes the
whole class of bug.
result on formulas you care about.
Excel recalculates on open, but Google Sheets import, pandas and most headless
parsers show a zero without it.
Styles
Declare once, reference by name. Repeating a font object per cell is the fastest way to turn a 20 MB request into a 200 MB one.
{
"styles": {
"header": {"font": {"bold": true}, "fill": {"color": "#1F2933"}},
"money": {"num_fmt": "#,##0.00"}
},
"sheets": [{
"name": "Sales",
"header_style": "header",
"columns": [{"header": "Item"}, {"header": "Amount", "style": "money"}],
"rows": [["Widget", 1250.5]],
"freeze": {"row": 1, "col": 0},
"autofilter": true
}]
}
A cell's style beats its row's, which beats its column's. Styles do not merge. A cell gets exactly the style it names, so nothing inherits a border it never asked for.
Row forms
Three, all equivalent. Use whichever your data already is.
["Widget", 1250.5] // positional
{"Item": "Widget", "Amount": 1250.5} // keyed by column
{"cells": ["Total", 4820], "style": "header"} // with row options
Keyed rows are placed by column order, not key order, so the two forms produce identical files.
Large exports
Send NDJSON to /v1/jobs: a header line describing the workbook, then one line per row. Nothing is ever held in full. A million rows costs tens of megabytes, not hundreds.
curl -X POST https://api.jarrah.sh/v1/jobs \
-H "Authorization: Bearer $JARRAH_KEY" \
-H "Content-Type: application/x-ndjson" \
--data-binary @export.ndjson
# export.ndjson
{"sheets":[{"name":"Ledger","columns":[{"header":"Date"},{"header":"Amount"}]}]}
["2026-08-01", 120.00]
["2026-08-02", 98.50]
Then poll GET /v1/jobs/{id} until status is succeeded, or supply a webhook and be told.
Errors
Every error is RFC 7807 application/problem+json. Write the handling once.
{
"type": "https://docs.jarrah.sh/errors/invalid-workbook",
"title": "Workbook failed validation",
"status": 422,
"detail": "2 problem(s) found…",
"errors": [
{"pointer": "/sheets/0/name", "message": "sheet name contains '/'…"},
{"pointer": "/sheets/0/rows/3/1", "message": "unknown style \"moneyy\"; did you mean \"money\"?"}
]
}
Every problem is reported at once, each located by a JSON Pointer into the body you sent. A generated payload gets fixed in one pass, not ten round trips.
| Status | Meaning | Retry? |
|---|---|---|
| 400 | Body is not valid JSON | No, fix it |
| 401 | Missing or invalid key | No |
| 413 | Too large for this endpoint | Use /v1/jobs |
| 422 | Workbook failed validation | No, fix it |
| 429 | Rate or concurrency limit | Yes, see Retry-After |
| 503 | Server at capacity | Yes, safe, nothing started |
A 429 can arrive while you are still uploading. Concurrency
is checked before a large /v1/jobs body is read, so the
response can come back mid-upload. That is ordinary HTTP and most clients
handle it, but some report it as a connection error rather than a status.
Python's urllib raises Errno 32 Broken pipe;
requests and several Node clients do something similar.
We read the body before answering so this stays rare, up to 16 MB.
Past that the connection closes mid-write and a strict client will surface
the write error instead of the 429. If you submit bodies larger than that,
treat a connection error on /v1/jobs as a possible
concurrency limit: re-check with GET /v1/jobs/{id} or simply
retry with backoff rather than assuming the upload failed.
curl is unaffected: it reads the early response. So is any
client sending Expect: 100-continue.
Webhooks
Supply webhook on a job and we POST when it finishes, signed with your account secret.
X-Jarrah-Signature: t=1786044014,v1=5f3a…
The signature covers "<timestamp>.<body>". Verify against the raw bytes. Parsing and re-serialising changes key order and the signature will stop matching.
Three attempts, at 0s, 30s and 120s. A webhook we cannot deliver never fails your job. The file stays downloadable regardless.
Limits
| Plan | Sync cells | Concurrent jobs | Requests / min |
|---|---|---|---|
| Free | 50,000 | 1 | 60 |
| Starter | 100,000 | 3 | 300 |
| Growth | 250,000 | 5 | 600 |
| Scale | 500,000 | 10 | 1,200 |
A note on CSV
Add ?format=csv to get CSV instead. Cells beginning =,
+, - or @ are prefixed with an apostrophe, because a
CSV opened in a spreadsheet executes them. An attacker-supplied display name
otherwise becomes code running on whoever opens your export. Pass
?escape_formulas=false only if the output is parsed rather than opened.
jarrah.sh, which is not registered yet, and the limits table reflects
placeholder pricing. Every code sample matches the API as built.