Importing data
Push a company's books through the API: accounts, master data, balances, lines, open items.
If your company's books live in your own system rather than in an ERP Accountflow fetches from, you push them through the API. This page takes you from an empty company to a loaded ledger. Removing or fixing what you pushed is on the corrections page.
Before you start
- Create the company in Accountflow (https://next.accountflow.com), as usual.
- Connect it to the Api accounting system. In the company's accounting-system
settings choose Api. There is nothing to fill in: choosing it is what tells
Accountflow that this company's data arrives through the API. Until you do, every
import answers
409withreason: company_not_api_managed. The check covers the year you are importing into as well: a year whose ledger was fetched from another accounting system is refused with the same409, even if the company is now bound to Api. - Create an API client with the
imports:writescope (see the quickstart). Addledger:readif the same client should read the data back. Imports are for system clients only.
Check where you stand at any time:
curl -s https://api.next.accountflow.com/v1/companies/$COMPANY/imports/readiness \
-H "Authorization: Bearer $TOKEN" | jq
{
"apiManaged": true,
"accountingSystem": "Api",
"currentYear": 2025,
"years": [
{ "year": 2025, "current": true, "accounts": 0, "mappedAccounts": 0,
"openingBalances": 0, "generalLedgerLines": 0, "lastImportAt": null }
],
"dimensions": 0,
"subLedgers": 0,
"blockers": ["no_accounts"],
"warnings": [],
"nextStep": "import_accounts"
}
blockers is what stops you importing; nextStep is the one thing to do next.
How every import works
Every import is the same four steps, whatever you push:
POST /v1/companies/{companyId}/imports/<kind>with up to 10 000 lines (and at most 8 MiB of body) and anIdempotency-Keyheader. More than that is413 payload_too_large: split the data into several requests.- The request is validated as a whole. Malformed input (a missing
year, aperiodoutside 1–12) is400 validation_failed; well-formed lines that break a rule answer422 unprocessablewith anerrorslist of{field, message, index}(index0-based into the array, up to 100 items) — and nothing is accepted. - You get
202 Acceptedwith an import:id,status: "queued",jobId. - Poll
GET /v1/companies/{companyId}/imports/{importId}untilstatusissucceededorfailed— or subscribe to thejob.succeeded/job.failedwebhook events for thejobId.GET /v1/jobslists the jobs your client submitted, if you lose an id.
{
"id": "0b8f…", "kind": "general_ledger_lines", "year": 2025, "mode": "merge",
"status": "succeeded", "jobId": "6d1c…", "datasetId": "0b8f…", "lineCount": 9800,
"rowsDiscovered": 9800, "rowsValid": 9795, "rowsFailed": 5,
"rowsInserted": 9700, "rowsUpdated": 95, "rowsDeleted": null,
"sampleErrors": ["line 12: missing required field: amount"],
"failureReason": null
}
- One import per kind and company runs at a time. A second one answers
409withreason: job_in_flightand thejobIdto wait for. Send large data as a sequence: wait for one import to finish, then send the next. - Retrying is safe. The same
Idempotency-Keyreturns the same import; it never creates a second one. succeededcan still carry failed lines.rowsFailedandsampleErrors(up to 100,line N: message, N counting from 1) tell you which lines were skipped. Fix and re-send just those; lines are merged byid, so re-sending the rest is harmless.- An import never stays
runningforever. If the job behind it crashes, the import settles asfailedwithfailureReason: internal_error; if the job is not picked up within its 30-minute lifetime,failureReason: job_expired. Both are safe to re-send with a newIdempotency-Key. OtherfailureReasonvalues are the refusalreasons from the corrections page (lines_in_use, …), andsampleErrors/inUseonly ever name your ids — never an internal one. - The request's preconditions are checked again when the job starts. If, while the
import waited, your client was suspended or revoked, the company was deleted or moved
out of your organization, or its ledger stopped being managed through the API, nothing
is uploaded and the import settles as
failedwithfailureReasonapi_client_inactive,company_unavailableorcompany_not_api_managed. - The request payload is kept for 24 hours for support and then reaped; the import record and its counters stay.
datasetIdis stamped on every row the import touched. Quote it to support.
Amounts are decimal strings ("-1250.00", at most four decimals) — that is how
the spec documents them; JSON numbers are accepted as well. Dates are ISO
(2025-03-14).
Ids, keys and account codes (id, key, parentKey, itemKey, matchKey,
accountCode, subLedgerKey, …) are yours, but they travel in paths and internal
keys, so they may not contain :, /, {, } or whitespace, and they are
bounded in length; a violation is 422 with the line in errors.
Reading back what you pushed
The read side of the API was designed from the ERP domain and uses its own names. The correspondence is exact:
| You send (import) | You read back | On |
|---|---|---|
accountCode |
accountNumber |
GET …/general-ledger/lines, GET …/accounts |
accountDescription |
name |
GET …/accounts |
accountCategory |
category |
GET …/accounts |
id (a general-ledger line) |
sourceLineId (lineId is Accountflow's own id for the same line) |
GET …/general-ledger/lines |
transactionNo |
voucherNo |
GET …/general-ledger/lines |
transactionCurrencyCode |
transactionCurrency |
GET …/general-ledger/lines |
The order to push in
| # | Kind | Endpoint | Scope |
|---|---|---|---|
| 1 | Chart of accounts | POST …/imports/accounts |
one accounting year |
| 2 | Dimension values (optional) | POST …/imports/dimensions |
the company |
| 3 | Sub-ledgers (optional) | POST …/imports/sub-ledgers |
the company |
| 4 | Opening balances | POST …/imports/opening-balances |
one accounting year |
| 5 | General-ledger lines | POST …/imports/general-ledger-lines |
one accounting year |
| 6 | Open items (optional) | POST …/imports/open-items |
customers or suppliers |
Accounts come first because everything else hangs on them: opening balances and
general-ledger lines that name an account code missing from that year's chart are
refused (422, reason: unknown_account_codes, with the codes). Dimensions and
sub-ledgers are optional up front — a line may name a key you have not pushed, and it
is created as a plain value — but pushing them first gives them proper names,
types and hierarchy.
1. Chart of accounts
curl -s -X POST https://api.next.accountflow.com/v1/companies/$COMPANY/imports/accounts \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" -d '{
"year": 2025,
"accounts": [
{ "accountCode": "1920", "accountDescription": "Bankinnskudd",
"accountCategory": "Balance", "currencyCode": "NOK" },
{ "accountCode": "3000", "accountDescription": "Salgsinntekt",
"accountCategory": "Result" }
]
}'
An existing accountCode is updated in place. Its standard-account mapping is never
touched by an import — mapping is done in Accountflow (or by auto-map), and
readiness.warnings shows unmapped_accounts while some remain. hidden: true
hides an account in the application; omit hidden to leave the flag as it is. A new
accounting year gets its own chart: push the accounts for each year you load.
2–3. Dimensions and sub-ledgers
{ "dimensions": [
{ "key": "DEPT", "value": "Departments", "type": "department" },
{ "key": "DEPT-10", "value": "Sales", "type": "department", "parentKey": "DEPT" } ] }
{ "subLedgers": [ { "key": "C-1001", "value": "Kunde AS", "type": "customer" } ] }
Keys are yours, stable, and unique per company (not per year). Read them back with
GET …/dimensions and GET …/sub-ledgers.
4. Opening balances
{ "year": 2025, "lines": [
{ "id": "OB-1920", "accountCode": "1920", "amount": "125000.00" },
{ "id": "OB-1500", "accountCode": "1500", "amount": "48200.00",
"subLedgerKeys": ["C-1001"] } ] }
id is your own stable id for the row: re-sending it updates the row. The trial
balance's opening column follows.
5. General-ledger lines
{ "year": 2025, "lines": [
{ "id": "INV-2025-000123-1", "accountCode": "3000", "amount": "-1250.00",
"period": 3, "transactionId": "V-2025-0456", "transactionNo": "456",
"description": "Salg mars", "transactionDate": "2025-03-14",
"taxCode": "3", "taxAmount": "-312.50", "taxPercentage": "25",
"dimensionKeys": ["DEPT-10"], "subLedgerKeys": ["C-1001"] } ] }
idis the line's identity — yours, stable, and free of:,/,{,}and whitespace. Re-send a line with the sameidand it is updated in place, with the previous version kept in history. Send a newidand you get a new line; there is no fuzzy matching.- You supply
period(1–12) andyear. Nothing is derived from dates. transactionIdgroups the lines of one voucher;transactionNois the voucher number accountants see.- Lines are merged, never replaced: a line you do not re-send stays. To remove one, delete it.
- When you read lines back (
GET …/general-ledger/lines), your id issourceLineId,accountCodeisaccountNumber,transactionNoisvoucherNoandtransactionCurrencyCodeistransactionCurrency(the full mapping is above).lineIdis Accountflow's own identifier for the same line.
6. Open items
Customer or supplier open items, with their matches, in the import contract's shape:
{ "subLedgerType": "customer", "mode": "incremental", "items": [
{ "itemKey": "inv-1001", "subLedgerKey": "C-1001", "subLedgerType": "customer",
"accountCode": "1500", "documentId": "inv-1001", "entryCategory": "invoice",
"documentDate": "2025-03-14", "dueDate": "2025-04-13",
"amount": "1250.00", "amountOpen": "250.00", "settlementStatus": "partial",
"matches": [ { "matchKey": "pay-77", "matchDate": "2025-04-01", "amount": "1000.00" } ] } ] }
incremental touches only what you send. full, full_refresh and baseline are
snapshots: items and matches absent from the snapshot are retired. Unlike the other
kinds, one invalid item fails the whole import — nothing is half-applied.
What importing does not do
Pushing data never triggers anything else: no reconciliation run, no VAT import, no notifications. Those happen on their own schedules or when someone starts them in Accountflow.