NUMBERS to CSV API
POST a .numbers file to /v2/convert/numbers/to/csv and each table comes back as a CSV, read straight from the file without an office suite. The sample's one table returns as account-statement.csv, 258 bytes.
- Endpoint
- POST /v2/
convert/ numbers/ to/ csv - Accepts
- numbers
- Sample timing
- 1 ms median of 5 runsHow we measure
- Request size
- Up to 10 MB on the Free plan or 100 MB on paid plans, all files together
Request and response
Every sample below ran against the API before this page was published. Set PDFCONVERSIONAPI_KEY to your key and run it as is.
curl -sS -X POST \
https://api.pdfconversionapi.com/v2/convert/numbers/to/csv \
-H "Authorization: Bearer $PDFCONVERSIONAPI_KEY" \
-F File=@account-statement.numbers \
-o response.json
# FileData is base64. Save the first file in converted/,
# or print the JSON error.
if jq -e '.Files' response.json > /dev/null; then
mkdir -p converted
name=$(jq -r '.Files[0].FileName' response.json)
jq -r '.Files[0].FileData' response.json \
| base64 --decode > "converted/$name"
else
cat response.json
fiimport base64
import os
import requests
key = os.environ["PDFCONVERSIONAPI_KEY"]
response = requests.post(
"https://api.pdfconversionapi.com/v2/convert/numbers/to/csv",
headers={"Authorization": f"Bearer {key}"},
files=[("File", open("account-statement.numbers", "rb"))],
timeout=180,
)
result = response.json()
if not response.ok:
raise SystemExit(f"{result['Code']}: {result['Message']}")
os.makedirs("converted", exist_ok=True)
for file in result["Files"]:
path = os.path.join("converted", file["FileName"])
with open(path, "wb") as out:
out.write(base64.b64decode(file["FileData"]))
print(path, file["FileSize"], "bytes")
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const form = new FormData();
const file = "account-statement.numbers";
form.append("File", new Blob([await readFile(file)]), file);
const url = "https://api.pdfconversionapi.com/v2/convert/numbers/to/csv";
const key = process.env.PDFCONVERSIONAPI_KEY;
const response = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${key}` },
body: form,
});
const result = await response.json();
if (!response.ok) {
throw new Error(`${result.Code}: ${result.Message}`);
}
await mkdir("converted", { recursive: true });
for (const file of result.Files) {
const path = join("converted", file.FileName);
await writeFile(path, Buffer.from(file.FileData, "base64"));
console.log(path, file.FileSize, "bytes");
}
{
"ConversionCost": 1,
"ConversionTime": 0.001,
"Files": [
{
"FileName": "account-statement.csv",
"FileExt": "csv",
"FileSize": 258,
"FileData": "RGF0ZSxUcmFuc2FjdGlvbiBE…"
}
]
}Response from our test run on 2026-11-03. ConversionTime shows the median of 5 runs. FileData is shortened here; the real response carries the whole file as base64.
The sample, converted
The same file the code sends. Download the input and the output and compare them yourself.
account-statement.csv, 4 lines
Date,Transaction Details,Paid In,Withdrawn,Balance,Category2020-05-01T00:00:00Z,Debit to Marlon Computing,250,,2021.5,Loans2020-05-01T00:00:00Z,Parking Downtown 932891,,2.99,2018.51,Entertainment2020-05-01T00:00:00Z,Grant Coffee,,13.25,2005.26,Eating Out
- Input
- account-statement.numbers (102 KB)
- Output
- account-statement.csv (258 bytes)
- Source
- A three-row account statement table saved by Numbers 12, taken from the MIT-licensed test files of an open-source Numbers reader; not made by us (MIT). How it was made
- Made by
- 2026-11-04, API build
9eeeb0640aba
Parameters
Send these as form fields next to the file. Names are case-insensitive. If you set one of the documented options below that we don't support yet, the request fails with a 400 that names it instead of returning a file made without it.
| Parameter | What it does |
|---|---|
FileNamestring | Stem of the CSV name. With one table the file is <stem>.csv; with several, each is <stem>-<sheet or table>.csv. |
StoreFileboolean, default false | Answer with links that work for 3 hours instead of the CSV text. |
What carries over, and what doesn't
Kept
- Every cell value as stored, unrounded: 2021.5, 2018.51, 2.99 and 13.25
- The header row (Date, Transaction Details, Paid In, Withdrawn, Balance, Category) as the first line
- Empty cells as empty fields, so the Paid In and Withdrawn columns line up
- Plain UTF-8 with LF line endings, no byte order mark
Lost or not yet supported
- Display formats. The GBP currency style and the date style are not applied; dates are written as 2020-05-01T00:00:00Z, with a time and a Z even when the cell holds only a day.
- Formulas: a formula cell gives the value Numbers last calculated.
- Fills, fonts, column widths and any charts or images.
- Delimiter choice: the output is always comma-separated, and a
Delimiterparameter is ignored.
Limits
- One Numbers file per request.
- Requests up to 10 MB on the Free plan or 100 MB on paid plans, all files and fields together.
- Files with more than 10,000 parts, or over 1 GiB unpacked, return 413.
- Tables beyond 1,048,576 rows or 16,384 columns return 413.
- 120 seconds per conversion, then 504.
Where teams use it
Statements and budgets kept in Numbers
People on Macs and iPhones keep money records in Numbers. Accept their .numbers upload and read the rows on a server that has no Apple software.
Feeding a database import
CSV is what most import tools read. The values arrive unformatted, so a balance of 2021.5 stays 2021.5 instead of a rounded currency string.
Checking what a file holds before you store it
Turn a user's spreadsheet into plain text to validate its columns, then keep the original.
Questions
What happens with a Numbers file that has several tables?
Each table becomes its own CSV in the same response. The files are named after the upload plus the sheet name, or the sheet and table name when a sheet holds more than one table. The sample has a single table, so the result is just account-statement.csv.
Why do dates include T00:00:00Z?
Numbers stores dates as moments in time, and this route writes them in full ISO 8601 form, such as 2020-05-01T00:00:00Z. If you need the day only, keep the first 10 characters. The XLSX route writes them as real date cells instead.
Do I need a Mac or Numbers installed?
No. The file is read directly by our converter on Linux servers. The sample was saved by Numbers 12 on a Mac and converted without any Apple software.
Can I get semicolons instead of commas?
Not yet. Every CSV from this route uses commas, and quotes only the fields that need it; a Delimiter parameter is accepted but changes nothing.
Convert your first NUMBERS file in a minute.