TableJSON
TableJSON

Guide

Bank statement PDFs into a spreadsheet

TableJSON has no bank mode. A statement is a table, so you use the same extract as a price list, then you do the bookkeeping checks yourself. That is a feature. Bank PDFs vary more than invoices: some put withdrawals and deposits in separate columns, some use a single signed amount, some repeat a running balance, and some stick the opening balance in the first row as if it were a transaction. A special parser that assumed one of those would lie about the others.

This is also a sensitive file. The upload is a temporary file on the server for the length of the request, then deleted. It is not added to a library. Still, a statement on a shared computer is a statement on a shared computer. Download the JSON, close the tab when you are done, and read the privacy page if you need the precise version of that sentence.

Extract first, then look at the shape

Highlight a date in the PDF. If you cannot, it is a scan and this extract will not recover the rows. The text-layer guide is the whole answer for that case. If you can, drop the file on the left card at tablejson.com and press Extract. Ignore the invoice card. A statement is not an invoice, and the checksum will fail closed for the wrong reason.

A typical text-layer statement comes back as one table because the header repeats on each page and matching headers are merged. page_number might be 1 and page_end might be 4. total_rows should be in the neighborhood of the transactions you can see, plus one if the opening balance was included. Download JSON. The on-screen table is paged at 50 rows so the browser stays usable. The file has the rest.

Here is a shortened row, the kind you actually get. Values are strings.

{
  "row_index": 2,
  "data": {
    "date": "03 Apr 2026",
    "description": "CARD 4412 GROCER",
    "debit": "42.18",
    "credit": "",
    "balance": "2,368.00"
  },
  "raw_cells": ["03 Apr 2026", "CARD 4412 GROCER", "42.18", "2,368.00"]
}

Look at that twice. data.credit is an empty string because the header existed, but raw_cells has four entries, not five. The blank credit column was dropped when the line was split. The balance slid into the credit position in the raw list, and the data object may have assigned 2,368.00 to credit and left balance empty, depending on how many pieces survived. I showed the hopeful data above. Trust raw_cells until the lengths match. This is the main statement bug, and it is described further in cleaning messy table JSON.

Put the blanks back before you sum anything

Decide which layout you have.

  • Two amount columns, debit and credit. A normal purchase has a debit and an empty credit. A paycheck has the opposite. If raw_cells is shorter than the header by one, insert "" at the empty side. Use the description and the way the balance moves to decide the side. A purchase that makes the balance fall is a debit.
  • One amount column, with a sign or the letters DR and CR. Do not invent a second column. Keep the sign as text until you parse it. Parentheses often mean negative. A trailing "DR" means the bank's convention, which you should read off the statement legend, not guess from a blog.
  • A running balance and no separate debit or credit. You can rebuild the movement by subtracting consecutive balances. Do that only after the rows are in statement order. A sort will destroy it.

Then make a sheet with one row per transaction. A practical header is date, description, debit, credit, balance, page. The page comes from the table's page_number and page_end. You will not get a per-row page if the pages were merged, so record the range on the import, not a fake page on every line. Parse dates with the format printed on the statement. 03 Apr 2026 and 03/04/2026 are different conversations in the US and the UK. Keep the original string in a column called date_raw so you can undo a bad parse.

Checks that catch a shifted column

Opening balance, plus credits, minus debits, equals closing balance. Compute it. If you are off by exactly one transaction, a row was dropped or doubled. If you are off by a huge number close to the balance itself, a balance was summed as if it were a debit. That is the slid-column bug. If you are off by a few cents, look for a row whose amount lost its separator, 42.18 read as 4218 or as 42.

The opening balance row is not a transaction. Banks print it as "OPENING BALANCE" or "BALANCE B/F" with an amount only in the balance column. If you sum it as a credit you will double the starting money. Tag that row and exclude it from the credit total. Do the same with "CLOSING BALANCE" if it appears as a last line. Use it as the target of the check, not as income.

Descriptions deserve a light touch. "CARD 4412 GROCER" might wrap onto a second PDF line and become its own one-cell row, which ends the table early. If tables_found is more than one on a simple statement, open each table before you append them. A second table that is only wrapped descriptions should be joined to the previous transaction, not imported as a payment with no amount.

A column map you can reuse

Once a given bank's PDF behaves, write the map down and stop rediscovering it. For a fictional "Harbor Civic" statement the map might say: header is Date, Details, Money out, Money in, Balance. Rename money_out to debit and money_in to credit. Insert a blank when raw_cells.length === 4. Drop rows matching /balance b\/f/i after copying the balance. Date format is D MMM YYYY. File that note next to the importer. Next month's PDF from the same bank will hit the same rules. A PDF from a different bank needs its own note. Do not point both at one heroic function.

CSV is fine as the sheet you hand to someone. Build it from the repaired rows, not from a blind dump of data, or the empty credit column will shift again inside the spreadsheet. The JSON versus CSV guide shows a conversion that quotes descriptions. Descriptions on statements are full of commas ("POS, GROCER, LONDON") and an unquoted CSV will split them into extra columns.

If you do this every month for a folder of clients, the playground will get old. POST /v1/extract-tables is the same engine, with a key, and the API guide has the loop. Keep the repair script beside that loop. Automating the extract without the balance check is how a shifted column posts quietly for a quarter.