Browse docs

CSV import

Import data in bulk from a CSV instead of one call at a time. The importer works across all three surfaces — the dashboard, REST (POST /api/v1/import), and MCP (import_csv) — and handles three targets:

TargetWhat it creates
contactsCustomers and vendors.
journal_entriesPre-balanced entries from a flat CSV where rows sharing an entry_ref form one entry.
bank_transactionsOne balanced draft journal entry per statement row.

The defining feature is content de-duplication: every row/entry is fingerprinted, so re-uploading the same file imports only genuinely-new rows and skips the rest. Retries are safe.

Contacts

Columns: name (required), contact_type (client | vendor | both; a customer value is treated as client), email, phone, address, tax_id, currency. De-dup is by tax_id/email, else name+type.

Journal entries

A flat CSV where rows sharing the same entry_ref form one entry. Columns: entry_ref, date, description, account (the account code or name — resolved server-side, not a UUID), debit, credit; optional reference, line_description. Each entry must have ≥2 lines and balance (debits = credits); unbalanced or inconsistent entries come back as clean per-entry errors. Entries land as drafts for review.

Bank transactions

Each statement row becomes a balanced draft entry: the bank leg books to bank_account_id, and the contra account is resolved in this order:

  1. a per-row category_account column (a pre-coded CSV), else
  2. a category_map you supply (normalized description → account code/name), else
  3. a memorized payee rule (see below), else
  4. the direction-aware default — money out → an expense account, money in → an income account.

Bank Debit/Credit is the bank's view

On a bank statement, Debit = money out and Credit = money in — the opposite of accounting Dr/Cr. The importer handles single signed amount, separate debit/credit columns, and amount+type. Set sign_convention only for a single signed-amount column with reversed polarity. Dates are parsed automatically — ISO (YYYY-MM-DD) is detected and used as-is, and MM/DD vs DD/MM is sniffed; set date_format only to override an ambiguous slash/dash format. A deposit is never booked to an expense account.

Default accounts are optional

Only bank_account_id is required. If you omit default_expense_account_id / default_income_account_id, uncategorized rows fall back to the seeded Uncategorized accounts — 9000 Miscellaneous Expense (out) and 4900 Other Income (in) — which every standard chart of accounts has. Pass explicit defaults to route them elsewhere.

Memorized payee rules

Teach the importer to auto-categorize a recurring payee so you don't re-send a category_map every time. A rule maps a payee (normalized the same way the importer matches — trimmed, whitespace-collapsed, lowercased) to a contra account, and applies ahead of the direction-aware default. In the dashboard, categorize rows and tick Remember payees; over MCP, use the rule tools:

ToolScopeDescription
set_payee_ruleswriteAdd/update rules: [{ description, account }] (account is a code/name).
list_payee_rulesreadList the org's rules.
delete_payee_rulewriteForget a rule.

A rule pointing at a now-inactive account is skipped at import time (the row falls back to the default), so a deactivated account never books silently. Rules are MCP + dashboard only — there's no REST endpoint.

Drafts, periods, and de-dup

  • Bank and journal imports land as drafts — review, then post in bulk (batch_post_journal_entries). A row dated inside a closed period is rejected per-row with PERIOD_CLOSED, even as a draft.
  • The bank de-dup fingerprint includes the bank account but excludes the chosen category — re-importing the same statement with a different categorization still de-dups, and the dashboard's per-row categorization never causes a duplicate on re-import.

Importing over REST

POST /api/v1/import as multipart/form-data with file, target, and an optional JSON options string.

bash
# Bank statement → draft entries (defaults fall back to Uncategorized)
curl -X POST "$FLYWHEEL_API/import" \
-H "Authorization: Bearer $FLYWHEEL_KEY" \
-F "target=bank_transactions" \
-F "file=@statement.csv" \
-F 'options={"bank_account_id":"<account-uuid>","date_format":"MM/DD/YYYY"}'

# 201 (all imported) or 207 (partial). Re-running imports nothing new:
# { "data": { "created": [...], "errors": [...], "skipped": [...],
#             "summary": { "total": 120, "created": 118, "failed": 0, "skipped": 2 } } }

Large files (async)

A synchronous import handles up to ~400 rows. A larger file — up to 50,000 rows — is queued on every surface and handed to a background worker. The worker walks the file in checkpointed windows across as many one-minute ticks as it needs, so "years of transactions" complete reliably and a crash resumes from the last checkpoint rather than restarting (re-runs are idempotent either way). Beyond 50,000 rows, split the file (ROW_LIMIT_EXCEEDED).

Over REST you get a 202 with a job id; poll it until it's done. The poll returns row_count and processed_offset so you can show a progress bar.

bash
# A large file returns 202: { "data": { "job_id": "...", "status": "pending" } }
curl "$FLYWHEEL_API/import/jobs/<job_id>" \
-H "Authorization: Bearer $FLYWHEEL_KEY"
# → { "data": { "status": "processing", "row_count": 12000, "processed_offset": 4000 } }
# → { "data": { "status": "completed", "result": { "summary": { ... } } } }

A queued job ends completed (read result.summary plus the errors/skipped samples) or failed (a top-level problem such as MISSING_BANK_OPTIONS, or — very rarely — abandoned after repeated worker crashes; see error).

MCP scale

Over MCP a large file is queued too: import_csv returns { status: "pending", job_id, row_count } and you poll get_import_job for progress and the final result. But you still emit csv_text inline, so a 50k-row MCP import is token-heavy — route a full statement through the dashboard or REST, which take the file directly with no inline-token cost.

Importing over MCP

json
import_csv {
"target": "contacts",
"csv_text": "name,contact_type,email\nAcme,customer,ar@acme.example\n..."
}
// → { "summary": { "total": 120, "created": 118, "skipped": 2, "failed": 0 },
//     "created": 118, "errors": [], "skipped": [...] }

Pass the CSV unchanged. If your headers differ from the canonical names, pass a mapping of canonical_field → your_header. import_csv is strictly better than create_contact / batch_create_journal_entries for a file: it passes the CSV through and de-dups re-uploads.

Failure handling

Each row ends up created, skipped (duplicate), or failed (invalid) — partial success is normal, so always check errors[] and skipped[].

CodeMeaning / fix
EMPTY_FILENo data rows. Check the header + rows.
ROW_LIMIT_EXCEEDEDOver the 50,000-row async cap. Split the file.
FILE_TOO_LARGEOver the 4MB route limit. Split the file.
MISSING_BANK_OPTIONSNo bank_account_id (or a default was omitted and the org has no active seeded 9000/4900).
INVALID_BANK_OPTIONSA supplied account id isn't an active account in this org.
VALIDATION_ERRORA row failed validation. Fix it and re-import — already-imported rows skip.
UNPARSEABLE_DATEA date isn't valid for the format (e.g. 02/30, or non-numeric). Fix the value or set date_format.
UNPARSEABLE_AMOUNTAn amount isn't a valid number. Fix the value.
UNRESOLVED_ACCOUNT / INACTIVE_ACCOUNTAn account / category didn't resolve to an active org account.
UNBALANCED_ENTRY / INVALID_LINE / INVALID_ENTRYA JE doesn't balance / a line is both-or-neither debit-credit / fewer than 2 lines.
INCONSISTENT_ENTRYLines of one entry_ref disagree on date/description/reference.
ZERO_AMOUNTA bank row has a zero amount.
PERIOD_CLOSEDThe row is dated inside a closed period (even as a draft).

MCP tools

import_csv, get_import_job (poll a queued import), set_payee_rules, list_payee_rules, delete_payee_rule. The full schema lives in the API reference; the step-by-step playbook is the import-csv skill.