Footed

Docs

How Footed works, and the API

What happens to a statement from drop to export, what the reconciliation statuses mean, the export formats, and the Pro API.

The pipeline

  1. Open in the browser. The PDF is opened with pdf.js on your device. Password-protected files are unlocked locally with the password you type; the password never leaves the page.
  2. Rebuild the lines. Footed takes every text fragment with its x/y position, groups fragments into visual lines, and splits lines into cells on large gaps. When it finds the transaction table's header row (Date, Description, Debit, Credit, Balance…), every row below is aligned to those columns, so an amount in the Withdrawals column stays a withdrawal.
  3. Scanned pages. A page with no text layer is rendered to a JPEG (at most 1600 px wide) and sent instead of text; a vision model transcribes it and the same parser reads the transcription.
  4. Structure. Pages are sent in small batches (the first page alone, then two at a time) to our API. A deterministic table reader tries first; when the batch's own printed balances prove its reading (running-balance chain or opening + transactions = closing), that result is used instantly. Otherwise a language model on Cloudflare Workers AI reads the statement details and transactions. The model copies amounts exactly as printed and says which direction money moved; all number parsing (1,234.56 · 1.234,56 · 1 234,56 · 1,23,456.78 · (45.20) · 45.20 Dr) and date handling happens in ordinary code that we test.
  5. Reconcile. Back in your browser, deterministic code checks the result (below). Nothing about the check depends on AI.

Reconciliation statuses

StatusMeaning
Reconciled ✓Every printed running balance follows from the previous one plus the row's amount (to the cent), and opening balance + all transactions = closing balance.
Reconciled with N flagged rowsThe totals foot, but some rows deserve a look: a possible duplicate, a date outside the statement period, or two errors that cancel out.
Couldn't reconcile — review highlighted rowsThe totals don't foot or a running balance breaks. The row where the chain breaks is highlighted with what the balance implies.
No balances printed — can't verifyThe pages we read show neither running balances nor opening/closing balances, so there is nothing to check against. Check the totals yourself.

Details that matter:

  • Balances printed once per day (common in the UK) are handled: the check runs across all rows since the last printed balance.
  • Newest-first statements are detected and checked in chronological order.
  • Credit cards: previous balance − payments/credits + purchases/fees/interest = new balance. Loan and credit-line accounts whose balance goes up when money goes out are detected automatically.
  • Multi-account statements (e.g. checking + savings in one PDF) are split into one statement per account, each reconciled on its own.
  • When the printed balance proves a sign is wrong, Footed flips it automatically and says so, with an Undo.

Fixing flagged rows

Every cell is editable: click a date, description or amount and type. Each flagged row offers the fix the numbers point to — flip the sign, use the amount the balance implies, or delete a likely duplicate — and the whole statement is re-checked as soon as you change anything. You can also add or delete rows.

Export formats

  • Excel (.xlsx): a Transactions sheet (real dates and numbers, not text), one sheet per statement when you convert several, and a Reconciliation sheet with opening, closing, totals and status per statement.
  • CSV: columns Date, Description, Amount, Money in, Money out, Balance, Currency, Account, Source, Page. Dates are ISO (2026-09-02); amounts are signed (+ in, − out) with a dot decimal and no thousands separators.
  • OFX 2.1: one file per account, importable into QuickBooks Online, Xero, Wave and most personal-finance apps. Each transaction gets a stable ID so re-importing the same statement doesn't duplicate rows.
  • JSON: statement details, the reconciliation result and every transaction with its flags.
  • Copy for Google Sheets: tab-separated rows on your clipboard; paste into cell A1.

Several statements are merged into one chronological table (with a Source column), and each keeps its own summary.

Limits & tips

  • Speed: a second or two when the table reader can prove its reading from the printed balances; 10–30 seconds per one or two pages when the AI reads them (pages run in parallel).
  • Results live only in this browser tab. Export before closing it.
  • Very faint or skewed scans may read poorly; the reconciliation will tell you.
  • Only the last four digits of account numbers are kept in results.

API (Pro)

The Pro plan includes API access with the same 2,000 pages per month. The API takes page text you have already extracted and returns structured, reconciled transactions. Server-side PDF parsing is not offered in v1 — extract the text on your side (example below), which also means the PDF never leaves your systems.

Base URL: https://footed-api.hrishikesh.workers.dev · Authentication: Authorization: Bearer <your Pro license key>

POST /v1/convert

Request body (JSON):

{
  "pages": [
    { "page": 1, "text": "First Harbor Bank | Statement period: 09/01/2026 - 09/30/2026\n[TABLE COLUMNS] Date | Description | Amount | Balance\n09/02 | AMAZON MKTPLACE | -76.72 | 3,325.45\n..." },
    { "page": 2, "text": "..." }
  ],
  "totalPages": 2,
  "hints": { "currency": "USD" }
}
  • pages: up to 60 pages per request, up to 25,000 characters each. One visual line per line; separate columns with " | ".
  • totalPages (optional): the statement's page count, if you send a subset.
  • hints (optional): period_start, period_end (ISO dates), currency, account_type (bank/credit_card), number_format (1,234.56, 1.234,56, 1 234,56), date_order (DD/MM/MM/DD).

Example:

curl -s https://footed-api.hrishikesh.workers.dev/v1/convert \
  -H "Authorization: Bearer $FOOTED_KEY" \
  -H "Content-Type: application/json" \
  --max-time 300 \
  -d @pages.json

Response (abridged):

{
  "pages": 2,
  "statements": [{
    "meta": { "bank": "First Harbor Bank", "account_last4": "4821", "account_type": "bank", "currency": "USD",
              "period_start": "2026-09-01", "period_end": "2026-09-30",
              "opening_balance": 3402.17, "closing_balance": 5354.15, "account_label": null },
    "transactions": [
      { "id": 1, "date": "2026-09-02", "description": "AMAZON MKTPLACE", "amount": -76.72, "balance": 3325.45, "page": 1, "flags": [] }
    ],
    "reconciliation": { "status": "reconciled", "label": "Reconciled ✓", "opening": 3402.17, "closing": 5354.15,
                        "computed_closing": 5354.15, "totals": { "in": 2140, "out": -188.02, "net": 1951.98, "count": 4 },
                        "running_balance_checks": 4, "running_balance_breaks": 0, "issues": [] }
  }],
  "warnings": [],
  "quota": { "plan": "pro", "limit": 2000, "used": 2, "remaining": 1998, "period": "month", "resets_at": "2026-11-01T00:00:00.000Z" }
}

status is one of reconciled, flagged, failed, unverifiable. Each transaction's flags lists what's wrong and, where possible, a suggested fix (flip, amount or delete). Requests take from about a second (statements whose balances prove the table reader's result) to 10–30 seconds per two pages (AI); use a generous client timeout.

Preparing page text

Any extractor works if it keeps one visual line per line and separates columns. For best results use the same line-rebuilding module the web app uses, /js/lines.js (a dependency-free ES module), with pdf.js in Node:

// npm i pdfjs-dist   ·   save https://footed.pages.dev/js/lines.js next to this file
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs';
import { readFileSync, writeFileSync } from 'node:fs';
import { reconstructPage } from './lines.js';

const doc = await pdfjs.getDocument({ data: new Uint8Array(readFileSync('statement.pdf')) }).promise;
const pages = [];
let carry = null;
for (let n = 1; n <= doc.numPages; n++) {
  const page = await doc.getPage(n);
  const r = reconstructPage((await page.getTextContent()).items, { pageWidth: page.getViewport({ scale: 1 }).width, carryColumns: carry });
  carry = r.columns;
  pages.push({ page: n, text: r.text });
}
writeFileSync('pages.json', JSON.stringify({ pages, totalPages: doc.numPages }));

Scanned pages (no text layer) aren't supported through the API in v1; use the web app for those.

Errors & quota

Errors are JSON: { "error": "code", "message": "…" }, often with the current quota.

HTTPerrorMeaning
400bad_request, too_many_pages, page_too_longFix the request body.
401invalid_licenseKey unknown, refunded or the subscription has ended.
402quota_exceededNot enough pages left; nothing was processed or charged.
403api_requires_proThe key is valid but not a Pro key.
429rate_limitedMore than 30 requests a minute from one IP.
502/503parse_failed, ai_capacityThe model couldn't read the pages or capacity is exhausted; retry later. Not charged.

Check your balance any time with GET /v1/quota (same Authorization header).

Enter your license key

Paste the key from your Gumroad receipt. It's saved in this browser only.