jarrah

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

That is the entire happy path. Everything below is refinement.

Sync or async

Two endpoints, one payload shape.

POST /v1/workbooksPOST /v1/jobs
ReturnsThe file202 and a job id
Good forUnder ~50k cellsAnything larger
Body limit25 MB250 MB
Streaming inputNoYes, 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"}]
Nothing is inferred. A string starting = 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.
Supply 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.

StatusMeaningRetry?
400Body is not valid JSONNo, fix it
401Missing or invalid keyNo
413Too large for this endpointUse /v1/jobs
422Workbook failed validationNo, fix it
429Rate or concurrency limitYes, see Retry-After
503Server at capacityYes, 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.

Reject stale timestamps. Without a tolerance, anyone who captures one delivery can replay it forever. Five minutes is a sensible window.

Three attempts, at 0s, 30s and 120s. A webhook we cannot deliver never fails your job. The file stays downloadable regardless.

Limits

PlanSync cellsConcurrent jobsRequests / min
Free50,000160
Starter100,0003300
Growth250,0005600
Scale500,000101,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.

Prototype. Design review only. Hostnames assume jarrah.sh, which is not registered yet, and the limits table reflects placeholder pricing. Every code sample matches the API as built.