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:
| Target | What it creates |
|---|---|
contacts | Customers and vendors. |
journal_entries | Pre-balanced entries from a flat CSV where rows sharing an entry_ref form one entry. |
bank_transactions | One 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:
- a per-row
category_accountcolumn (a pre-coded CSV), else - a
category_mapyou supply (normalized description → account code/name), else - a memorized payee rule (see below), else
- 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:
| Tool | Scope | Description |
|---|---|---|
set_payee_rules | write | Add/update rules: [{ description, account }] (account is a code/name). |
list_payee_rules | read | List the org's rules. |
delete_payee_rule | write | Forget 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 withPERIOD_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.
# 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.
# 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
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[].
| Code | Meaning / fix |
|---|---|
EMPTY_FILE | No data rows. Check the header + rows. |
ROW_LIMIT_EXCEEDED | Over the 50,000-row async cap. Split the file. |
FILE_TOO_LARGE | Over the 4MB route limit. Split the file. |
MISSING_BANK_OPTIONS | No bank_account_id (or a default was omitted and the org has no active seeded 9000/4900). |
INVALID_BANK_OPTIONS | A supplied account id isn't an active account in this org. |
VALIDATION_ERROR | A row failed validation. Fix it and re-import — already-imported rows skip. |
UNPARSEABLE_DATE | A date isn't valid for the format (e.g. 02/30, or non-numeric). Fix the value or set date_format. |
UNPARSEABLE_AMOUNT | An amount isn't a valid number. Fix the value. |
UNRESOLVED_ACCOUNT / INACTIVE_ACCOUNT | An account / category didn't resolve to an active org account. |
UNBALANCED_ENTRY / INVALID_LINE / INVALID_ENTRY | A JE doesn't balance / a line is both-or-neither debit-credit / fewer than 2 lines. |
INCONSISTENT_ENTRY | Lines of one entry_ref disagree on date/description/reference. |
ZERO_AMOUNT | A bank row has a zero amount. |
PERIOD_CLOSED | The 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.