Everything the converter does is available over HTTPS: upload a statement, poll until it's parsed and balance-checked, then download the result in any format. One endpoint for each of those, plus one that reports your page balance: four in all, no SDK required — and a machine-readable OpenAPI 3.1 specification if you'd rather generate a client or import the API into Postman.
Authentication
Owners and admins create keys in Settings → Team → API keys. The key is shown once — store it like a password, and send it as a bearer token on every request:
Authorization: Bearer nrk_vZxd79iwXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Keys belong to the team: conversions they create count against the team's page allowance and appear in the team's history, attributed to the API.
1. Upload a statement
curl https://norekey.com/api/v1/conversions \
-H "Authorization: Bearer $NOREKEY_KEY" \
-F "statement=@january.pdf"
Add -F "password=..." for a password-protected PDF — the password is used to open the file and never stored. You'll get 201 Created back immediately, before parsing starts:
{
"id": "01kx90e4hpjjxhkrzb6fz1kja1",
"statement": "january.pdf",
"status": "pending",
"pages": 1,
"scanned": false,
"pages_charged": 1,
"bank": null,
"currency": null,
"balanced": null,
"opening_balance": null,
"closing_balance": null,
"error": null,
"formats": ["csv", "xlsx", "ofx", "qfx", "json"],
"created_at": "2026-07-14T09:12:03+00:00",
"completed_at": null
}
Prefer JSON to multipart? Send the file base64-encoded — handy from serverless functions and no-code tools:
curl https://norekey.com/api/v1/conversions \
-H "Authorization: Bearer $NOREKEY_KEY" \
-H "Content-Type: application/json" \
-d "{\"filename\": \"january.pdf\", \"statement\": \"$(base64 -i january.pdf)\"}"
Base64 inflates payloads by about a third, so multipart is the better choice for very large statements.
2. Poll until it's done
curl https://norekey.com/api/v1/conversions/01kx90e4hpjjxhkrzb6fz1kja1 \
-H "Authorization: Bearer $NOREKEY_KEY"
Most statements finish in 10–30 seconds; polling every 2–3 seconds is plenty. When status is completed, the metadata is filled in — including balanced, the same verification the web converter runs:
{
"id": "01kx90e4hpjjxhkrzb6fz1kja1",
"statement": "january.pdf",
"status": "completed",
"pages": 1,
"scanned": false,
"pages_charged": 1,
"bank": "HSBC",
"currency": "GBP",
"balanced": true,
"opening_balance": "3425.00",
"closing_balance": "6320.09",
"error": null,
"formats": ["csv", "xlsx", "ofx", "qfx", "json"],
"created_at": "2026-07-14T09:12:03+00:00",
"completed_at": "2026-07-14T09:12:21+00:00"
}
| Status | Meaning |
|---|---|
pending |
Accepted, waiting for a worker. |
processing |
Being parsed and balance-checked right now. |
completed |
Done — exports are ready. Check balanced. |
failed |
Couldn't convert; error says why in plain English. |
expired |
Rows passed your retention window; exports are gone. |
A balanced of true means the rows reconcile exactly against the statement's own opening and closing balances. false means they don't — the data is still downloadable, but check it before importing. null means the statement declared no totals to verify against.
3. Download the export
The final path segment is the format — anything in the formats array works, and new formats join automatically:
curl -O -J https://norekey.com/api/v1/conversions/01kx90e4hpjjxhkrzb6fz1kja1/export/csv \
-H "Authorization: Bearer $NOREKEY_KEY"
The json export is the richest: metadata, declared totals, the balance verdict and every row, with amounts as decimal strings so nothing is lost to float precision:
{
"statement": "january.pdf",
"bank": "HSBC",
"currency": "GBP",
"opening_balance": "3425.00",
"closing_balance": "6320.09",
"balanced": true,
"transactions": [
{
"date": "2026-01-04",
"description": "SAINSBURYS S/MKT",
"amount": "-57.56",
"balance": "3367.44"
},
{
"date": "2026-01-24",
"description": "BACS SALARY ACME LTD",
"amount": "3597.17",
"balance": "6964.61"
}
]
}
Check your page balance
Before sending a batch, ask how many pages the team has left. The call costs no pages:
curl https://norekey.com/api/v1/usage \
-H "Authorization: Bearer $NOREKEY_KEY"
{
"team": { "name": "Acme Bookkeeping", "slug": "acme-bookkeeping" },
"plan": "practice",
"cycle": {
"started_at": "2026-09-14T09:30:00+00:00",
"resets_at": "2026-10-14T09:30:00+00:00"
},
"pages": {
"allowance": 2500,
"used": 1812,
"remaining": 688,
"credit": 50,
"available": 738
},
"limits": {
"max_pages_per_file": 30,
"max_file_mb": 20,
"scanned_page_multiplier": 1
}
}
| Field | Meaning |
|---|---|
allowance |
Pages your plan includes each cycle. |
used |
Pages charged this cycle, including conversions still in progress. Failed conversions are refunded. |
remaining |
What's left of the allowance. Never negative. |
credit |
Extra pages granted to your team. Spent only after the allowance runs out, and kept from cycle to cycle. |
available |
remaining plus credit: the most pages your next upload can be charged. |
cycle.resets_at |
When used goes back to zero. Paid teams' cycles follow their billing date. |
An upload is accepted when its pages_charged (its page count) is no more than available. scanned_page_multiplier is always 1 now that scans cost the same as any other page; it stays in the response so existing clients keep working. Otherwise it's refused with quota_exceeded. On an unlimited plan, allowance, remaining and available are null.
The balance moves the moment a conversion is accepted, so read it just before a batch rather than caching it for long:
usage = requests.get(f"{API}/usage", headers=HEADERS).json()
available = usage["pages"]["available"]
if available is not None and available < pages_needed:
print(f"Only {available} pages left until {usage['cycle']['resets_at']}")
A complete script
Python:
import requests, time
API = "https://norekey.com/api/v1"
HEADERS = {"Authorization": "Bearer nrk_..."}
with open("january.pdf", "rb") as f:
conversion = requests.post(
f"{API}/conversions", headers=HEADERS, files={"statement": f}
).json()
while conversion["status"] in ("pending", "processing"):
time.sleep(2)
conversion = requests.get(
f"{API}/conversions/{conversion['id']}", headers=HEADERS
).json()
if conversion["status"] == "completed" and conversion["balanced"]:
csv = requests.get(
f"{API}/conversions/{conversion['id']}/export/csv", headers=HEADERS
)
open("january.csv", "wb").write(csv.content)
else:
print("needs attention:", conversion["error"] or "totals need review")
Node:
const API = 'https://norekey.com/api/v1';
const headers = { Authorization: 'Bearer nrk_...' };
const form = new FormData();
form.append(
'statement',
new Blob([await fs.readFile('january.pdf')]),
'january.pdf',
);
let conversion = await (
await fetch(`${API}/conversions`, { method: 'POST', headers, body: form })
).json();
while (['pending', 'processing'].includes(conversion.status)) {
await new Promise((r) => setTimeout(r, 2000));
conversion = await (
await fetch(`${API}/conversions/${conversion.id}`, { headers })
).json();
}
const rows = await (
await fetch(`${API}/conversions/${conversion.id}/export/json`, { headers })
).json();
console.log(
`${rows.transactions.length} transactions, balanced: ${rows.balanced}`,
);
Errors
Every error is JSON with a code and a human-readable message:
{
"code": "quota_exceeded",
"message": "This 3-page statement would take your team over its monthly page allowance."
}
| Code | Status | When |
|---|---|---|
unauthenticated |
401 | Missing, wrong or revoked key. |
password_required |
422 | Protected PDF uploaded without its password. |
invalid_statement |
422 | Base64 payload that isn't valid base64 or isn't a PDF. |
too_many_pages |
422 | Over the per-file page cap. |
quota_exceeded |
422 | Not enough pages left: check GET /api/v1/usage. |
| — | 404 | Conversion doesn't exist or belongs to another team. |
| — | 410 | Conversion expired under your retention settings. |
Scanned statements
Scanned or photographed PDFs convert through OCR automatically — the API never prompts. The response marks them with "scanned": true, and they're charged like any other upload: one page of allowance per page.
Limits, on every plan
API access isn't gated behind a paid tier — every team, Free included, can create keys and convert programmatically. Requests are limited to 120 per minute per key, and conversions consume your plan's page allowance exactly as web uploads do. GET /api/v1/usage shows where you stand at any time. Something missing for your integration? Tell us — support@norekey.com.