Skip to content

API reference

One endpoint to upload, one to poll, one to download. Everything returns JSON except the file endpoints, which stream the converted document.

http://localhost:8080/api/v1 Stable — v1

Quickstart

Post one or more PDFs as multipart form data. Small files come back converted immediately with 200; larger ones return 202 and a job to poll.

curl
curl -X POST http://localhost:8080/api/v1/convert \
  -F "[email protected]" \
  -F "[email protected]" \
  -F "password=hunter2"

Endpoints

POST /convert

Upload one or more PDF statements.

Multipart request. Returns 200 with a completed Job for small files, or 202 with a queued Job to poll. Maximum 20 files per request, 25 MB each.

FieldTypeRequiredDescription
filesfile[]requiredRepeatable PDF part. One entry per statement.
passwordstringoptionalPassword for encrypted PDFs. Applies to every file in the request.
GET /jobs/{job_id}

Fetch job state.

Poll until status is completed, failed or needs_password. Documents appear in the array as they finish, so partial results are readable while the rest are still parsing.

FieldTypeRequiredDescription
job_idstringrequiredIdentifier returned by POST /convert.
POST /jobs/{job_id}/unlock

Supply the password for an encrypted document.

Retries the locked documents of an existing job in place. The job id and every document id are preserved, so edits already made to other files in the batch survive. 200 if anything unlocked, 401 if the password was wrong (the document stays needs_password and is retryable), 409 if nothing is locked.

FieldTypeRequiredDescription
passwordstringrequiredThe PDF password to try.
document_idstringoptionalUnlock one document. Omit to try the password on every locked document — a batch may carry files with different passwords.
GET /jobs/{job_id}/documents/{doc_id}/download

Stream the converted file as parsed.

Responds with the file body and Content-Disposition: attachment. Use this when the client has made no edits.

FieldTypeRequiredDescription
formatenumrequiredOne of csv, xlsx, json, ofx. Query parameter.
columnsstringoptionalComma-separated field list, in order — for example date,description,debit,credit,balance. Omit for the statement’s own layout.
GET /jobs/{job_id}/download

Download every statement in the job at once.

mode=combined merges the batch into one file carrying Source and Account columns — and, for XLSX, a Summary sheet with each statement’s totals and balance check. mode=zip returns an archive of the individual files instead.

FieldTypeRequiredDescription
formatenumrequiredOne of csv, xlsx, json, ofx.
modeenumoptionalcombined (default) or zip.
columnsstringoptionalComma-separated field list, as on the single download.
POST /jobs/{job_id}/documents/{doc_id}/export

Re-export using client-edited rows.

Send the corrected transaction array back and the file is rendered from those rows instead of the original extraction. Streams the file the same way download does.

FieldTypeRequiredDescription
formatenumrequiredOne of csv, xlsx, json, ofx.
transactionsTransaction[]optionalThe full, edited row set to render. Omit to re-export the stored rows — which is how a pure column re-map avoids resending every row.
columnsColumn[]optionalColumn layout as { field, header? } objects, in order. Unlike the query-string form, this carries renamed headings.
GET /quota

Remaining allowance for the caller.

Anonymous callers are identified by IP. Metered in pages, not files, on a rolling window. Returns used, limit, remaining and resets_at — an ISO timestamp. A 429 also carries a Retry-After header in seconds.

GET /health

Liveness probe.

Returns status and the deployed version string.

Polling a job

A job is terminal once its status is completed, failed or needs_password. Poll about once a second; documents populate incrementally.

JavaScript
const created = await fetch(`${BASE}/convert`, {
  method: 'POST',
  body: form
}).then((r) => r.json());

let job = created;
while (!['completed', 'failed', 'needs_password'].includes(job.status)) {
  await new Promise((r) => setTimeout(r, 1200));
  job = await fetch(`${BASE}/jobs/${job.job_id}`).then((r) => r.json());
}

const doc = job.documents[0];
const file = await fetch(
  `${BASE}/jobs/${job.job_id}/documents/${doc.id}/download?format=csv`
).then((r) => r.blob());

Column layouts

A statement has one natural shape, but the file you need depends on what you are importing into. Rather than guess, name the columns you want. The most consequential choice is between one signed amount column and a debit/credit pair, which splits that one signed figure into two positive-magnitude columns.

FieldMeaning
dateTransaction date, YYYY-MM-DD.
value_dateValue date, where the statement prints one.
descriptionNarrative as printed.
amountSigned: negative is money out.
debitMoney out, as a positive figure. Blank on credits.
creditMoney in, as a positive figure. Blank on debits.
balanceRunning balance, where the statement prints one.
currencyISO code for the account.
accountMasked account number.
sourceThe uploaded file the row came from.
pagePage of the PDF the row was read from.
confidenceExtraction confidence, 0–1.
cURL
# Two positive columns, what accounting imports expect
curl "${BASE}/jobs/${jobId}/documents/${docId}/download\
?format=csv&columns=date,description,debit,credit,balance"

# Renamed headings need the POST form
curl -X POST "${BASE}/jobs/${jobId}/documents/${docId}/export" \
  -H 'Content-Type: application/json' \
  -d '{"format":"csv","columns":[{"field":"date","header":"Posted"},{"field":"debit"}]}'

# The whole batch, merged into one workbook
curl "${BASE}/jobs/${jobId}/download?format=xlsx&mode=combined"

Objects

Job

status is one of queued, processing, completed, failed or needs_password.

Job
{
  "job_id": "j_9f2a4c81",
  "status": "completed",
  "created_at": "2026-09-05T10:00:00Z",
  "documents": [ /* Document objects */ ]
}

Document

amount is signed — negative is money out. balance is null when the statement has no balance column. summary.balance_check is passed, failed or unavailable.

Document
{
  "id": "d_1a2b",
  "filename": "chase-jan.pdf",
  "status": "completed",
  "error": null,
  "pages": 4,
  "bank": { "id": "chase", "name": "Chase", "confidence": 0.97 },
  "account": {
    "number": "****1234",
    "holder": "JANE DOE",
    "type": "checking",
    "currency": "USD",
    "period_start": "2024-01-01",
    "period_end": "2024-01-31",
    "opening_balance": 1204.11,
    "closing_balance": 2093.56
  },
  "columns": ["date","description","debit","credit","balance"],
  "transactions": [
    {
      "index": 0,
      "date": "2024-01-03",
      "description": "AMAZON MKTPL AMZN.COM/BILL",
      "amount": -42.10,
      "balance": 1162.01,
      "page": 1,
      "confidence": 0.99
    }
  ],
  "summary": {
    "count": 83,
    "total_in": 5100.00,
    "total_out": -4210.55,
    "net": 889.45,
    "balance_check": "passed",
    "confidence": 0.98
  },
  "warnings": ["Page 3: 2 rows had no balance column"]
}

Errors

Every 4xx and 5xx response carries the same envelope.

Error response
{
  "error": {
    "code": "encrypted_pdf",
    "message": "Password required to open this document."
  }
}
CodeHTTPMeaning
invalid_pdf400The file is not a readable PDF, or has no text layer.
encrypted_pdf401The PDF is encrypted and the password was missing or wrong.
too_large413A file exceeded the 25 MB per-file limit.
too_many_files400More than 20 files in a single request.
quota_exceeded429The caller has used their allowance for the period.
scanned_pdf422The pages are images with no text behind them. Needs OCR.
no_transactions_found422Parsing succeeded but produced no transaction rows.
nothing_locked409Unlock was called on a job with no document awaiting a password.
not_converted409Batch download was called on a job where nothing converted.
not_found404The job expired or never existed.
internal500Unexpected server-side failure. Safe to retry.

Limits

  • 20 files per request
  • 25 MB per file
  • 5 conversions per day for anonymous callers
  • 5,000 API calls per month on Pro
  • Converted data is deleted 1 hour after upload

Need higher volume or a dedicated instance? See Business pricing or get in touch.