# Twinfield — Accounting API: gotchas

> Canonical URL: https://developers.apideck.com/apis/accounting/twinfield/gotchas

## GET /accounting/tax-rates

Listing tax rates returns only the VAT code and its description; `effective_tax_rate` and
`total_tax_rate` are not populated on the list operation. Reading a single tax rate by its
known VAT code does return the rate.

`name` and `description` are read from a Dutch-language field key. Verified only against a
Dutch-language office; on a non-Dutch-language office these fields would come back empty.

## GET /accounting/bills

This connector fetches every matching bill in a single call — there is no server-side
pagination, so a large date range can be a large response. The `id` returned matches what
`bills.get` expects, and other transaction types (bank, journal entries) are excluded.

`sub_total`, `total_tax`, `line_items` and `updated_at` are not available from this list
view — only one summary row per bill is returned, not the underlying line items. `total`
is the posted gross value (net + VAT once a bill carries tax). `balance` and `paid_date`,
notably, ARE available here even though they are always null on `GET` by id — this list
surfaces data the single-record read does not.

## POST /accounting/bills

Both `supplier.id` and `bill_number` are required — Twinfield rejects a bill without an invoice number, and every line must post against the accounts-payable control account, which requires knowing the supplier.

The accounts-payable control account itself is per-organisation configuration Twinfield exposes no way to look up. It defaults to `1600` (Twinfield's standard chart of accounts); if this office uses a different code, configure it on the connection.

`reference` is not sent — Twinfield's purchase transaction header has no free-text field
distinct from the invoice number (`invoicenumber`, already used for `bill_number`) to hold
it. Treat `reference` as write-unsupported for this connector.

`line_items[].tax_rate.id` is sent to Twinfield as a per-line `vatcode`; Twinfield computes
the VAT and posts it as its own line, so the balancing control-account line must equal net +
VAT, not just net. The connector computes that VAT total itself — using each line's
`tax_amount` when supplied, otherwise a live lookup of the `tax_rate.id` code's percentage —
and adds it to the control-account line. When that lookup cannot resolve a percentage (an
unknown code, or the lookup call itself failing), the connector defaults the line's
contribution to zero rather than failing the request outright; if Twinfield's own `vatcode`
computes a nonzero VAT for that line regardless, the resulting mismatch causes Twinfield to
reject the write with its own balance error rather than silently posting an unbalanced
amount.

## GET /accounting/bills/{id}

`total_tax`, `balance` and `paid_date` are always null on this get-by-id operation —
Twinfield's response omits the balancing line where those values would come from.
`sub_total` is set equal to `total` rather than left null, since this connector does not
currently post VAT on bills (see the bill create gotcha), so there is no tax to subtract.
`balance` and `paid_date` are available on `bills.list`.

## GET /accounting/invoices

This list only returns unpaid invoices — Twinfield does not expose a way to list paid or
historical invoices. `status`, `line_items`, `invoice_date`, `due_date`, `currency`,
`sub_total`, `total_tax` and `balance` are not available on the list; fetch the invoice by
`id` for those fields.

Because of this, webhook notifications only reliably cover new invoices — once an invoice
is paid it drops out of this list entirely rather than appearing as an update, so a status
change to paid will not reliably produce a webhook event.

Several fields are read from Dutch-language field keys. Verified only against a
Dutch-language office; on a non-Dutch-language office this list may return empty.

`meta.total_count` may overstate the true number of invoices: the underlying list mixes
unpaid bills into the same count, and pagination needs the unfiltered total to reliably
fetch every page.

## POST /accounting/invoices

`line_items[].ledger_account.id` must reference a profit-and-loss (income or expense)
ledger account — Twinfield rejects an invoice line that posts to a balance-sheet account
(e.g. a bank or receivables account) with "Grootboekrekening bestaat niet (in de
PNL-groep)".

`line_items[].description` is not preserved on the created invoice line. Twinfield derives
the line's description from the ledger account it posts to, so the created invoice shows a
fixed label for that ledger account instead of the text you sent.

## GET /accounting/invoices/{id}

`line_items[].total_amount` is each line's net amount (excl. VAT), computed from
Twinfield's own `unitspriceexcl` × `quantity`. The VAT-inclusive gross total is `total`
(the invoice's header field, read from `totals.valueinc`) — Twinfield's response carries
no per-line gross figure to map instead.

## GET /accounting/ledger-accounts

`active` and `status` are not returned by this list call — fetch the ledger account by id
to read these fields.

This list includes both balance-sheet and profit & loss accounts, distinguished by `type`.

## POST /accounting/ledger-accounts

Twinfield requires the account's dimension type (`BAS` for `type: "balancesheet"`, `PNL` for
`type: "other"`) up front, and its own validation enforces the code format per type: balance
sheet codes must match `[0-3][0-9][0-9][0-9]`; anything else is rejected as Profit & Loss.
Send `code` in the matching range for the `type` you request — a `type: "other"` code inside
the `0`–`3` range is rejected by Twinfield's own format check before this connector's mapping
is even reached.

Fetching, updating and deleting an existing account by id derives its type from the same
leading-digit rule rather than a caller-supplied value, since Twinfield's read/update/delete
calls need the dimension type up front and the unified request has no `type` field to read it
from at that point. This is Twinfield's own enforced convention, not a per-tenant setting.

## GET /accounting/customers

`status` is not returned by this list call — the underlying vendor list only returns code
and name per row. Fetch the customer by id to read it. The same applies to `emails`, `phone_numbers`, `addresses`,
`websites`, `tax_number`, `bank_accounts`, `updated_at` and `account` — all empty/null on
the list response, populated only when fetched by id.

## GET /accounting/suppliers

`status` is not returned by this list call — the underlying vendor list only returns code
and name per row. Fetch the supplier by id to read it. The same applies to `emails`, `phone_numbers`, `addresses`,
`websites`, `tax_number`, `bank_accounts`, `updated_at` and `account` — all empty/null on
the list response, populated only when fetched by id.

## GET /accounting/payments

Twinfield returns every matching payment in a single call for this endpoint — there is no
server-side pagination, so a large date range can be a large response.

`account` and `allocations` are not populated on the list. Fetch the individual payment by
`id` to get those fields.

## POST /accounting/payments

Disabled as of 2026-08-18. Twinfield's bank transaction requires a reference to an existing,
already-imported bank statement line — a value the caller cannot supply directly, and this
connector has no way to discover one (its bank-statement-import path is itself disabled).
Separately, `account.id` must be a real ledger-account code (e.g. `1010`); the id returned
by `/accounting/bank-accounts` is a different Twinfield concept and does not work here.

## GET /accounting/journal-entries

This connector fetches every matching journal entry in a single call — there is no
server-side pagination, so a large date range can be a large response. An entry with
multiple lines is grouped back into one record with a nested `line_items` array.

`journal-entries.list` also returns records that were created through this connector's
`expenses` resource — there is no field to tell the two apart. Treat any entry whose lines
look like a two-line debit/credit posting as a possible `expenses` record.

`memo`, `posted_at` and `updated_at` are always null on the list. Fetch the entry by id
for those fields.

Twinfield transaction numbers are not globally unique — the same number can also identify
a `bills`/`expenses` record, or a transaction entered directly in Twinfield, and this
connector has no field to disambiguate. Getting a journal entry by an id sourced from
another resource (or from Twinfield directly) can silently return the wrong record.

## POST /accounting/journal-entries

Twinfield caps a line's description at 40 characters. A longer value is rejected outright
rather than truncated, and this connector does not pre-truncate
`line_items[].description` before sending.

## GET /accounting/general-ledger-transactions

This connector fetches every matching transaction in a single call — there is no
server-side pagination, so a large date range can be a large response. `currency_rate` and
`reference` are not available. Transactions created through this connector's `expenses`
resource are indistinguishable from manual journal entries and are returned as
`source_type: journal_entry`.

`posted_at` is approximated as the first day of the transaction's accounting period
(month), not the actual posting date, since the connector's data source for this endpoint
does not expose the real date.

Getting a specific transaction by `id` is not supported — Twinfield's transaction numbers
are not globally unique across transaction types, so there is no reliable way to look one
up directly.

## GET /accounting/bank-accounts

Only account `code` and `name` are available — `account_type`, `currency`, `balance`,
`available_balance`, `bank_name`, `account_number`, `iban` and `status` are not returned
by this connector.

`name` is read from a Dutch-language field key. Verified only against a Dutch-language
office; a non-Dutch office may return this field empty.

## GET /accounting/journals

`type` is derived from the daybook code (e.g. purchase, sales, bank, general); a code outside that known set reports as `other`. `allow_vat` and `currency` are not exposed and are always absent. `default_account` is only present for `bank`/`cash`-type journals, where it references the same account returned by `bank-accounts`; other journal types have no linked account and omit the field.

## GET /accounting/fiscal-periods

Twinfield returns periods one fiscal year at a time, so a list makes one extra request per year and covers at most the 10 most recent years, plus one request for the year before the oldest so its first `start_date` matches a get-one; if any of those years cannot be read the list fails with a 502 rather than returning a gap. Ids have the form `{year}-{period number}` (for example `2026-01`). Twinfield returns no start date: `start_date` is the day after the previous period ends and is null for the earliest period Twinfield has. `fiscal_year` is the calendar year in which the fiscal year ends (the year of its last period's end date), whatever Twinfield calls the year; `name` is Twinfield's period name. `status` is `open` or `closed` from Twinfield's open flag: a period that is not open accepts no postings from anyone until it is reopened (https://taasupportportal.wolterskluwer.com/nl/nl-nl/artikel/perioden-3041044), and `locked` and `future` are not used. `locked_through` is null. `is_adjustment`, `created_at` and `updated_at` are not available.

## GET /accounting/fiscal-periods/{id}

For the first period of a year, `start_date` needs the previous year's periods, which costs one extra request; it is null if that year cannot be read. A malformed id, or a year the office does not have, answers 404 before any period is requested; a period number the year does not have answers 404 as well.

## GET /accounting/trial-balance

Built from Twinfield's "Annual report (totals)" browse (code 040_1), which returns each period's activity per general ledger account, signed debit positive (confirmed live). `filter[end_date]` (today by default) is resolved to the period that contains it in the office's own fiscal calendar (PeriodService), and the report is as of the end of that period (`end_date`); `start_date` is the first day of that fiscal year. Year-end handling follows Wolters Kluwer's Twinfield documentation (the year-end closing checklist and the support articles "Hoe zet ik een beginbalans in Twinfield" and "Waarom staat er een dubbel saldo op mijn balans in periode 0?"): closing a year, concept or definitive, posts the next year's opening balance into period 0, built from the totals of the balance sheet accounts; a concept closing can be re-run and then overwrites it. So balance sheet accounts are summed from period 0 of the latest year that has period-0 lines (concept and final lines alike), or over the whole history when no year has any; income and expense accounts cover the fiscal year from period 1. Period 56 (the closing entries in the old year: result reversal and transfer to equity, write-offs) is always left out, and period 55 (year-end adjustments) is included only when `end_date` reaches the end of the year's last regular period. Results of earlier years that are not yet closed are returned as one extra line, "Result of previous fiscal years (not yet appropriated)", with `line_type` `unappropriated_result` and null `account_id` and `code`; every other line has `line_type` `account` and the account code as `account_id`, which is the id `GET /accounting/ledger-accounts/{id}` takes. A balance entered manually in period 0 and then also generated by a closing appears twice in period 0 (a known Twinfield behaviour); the report passes through what Twinfield holds. Opening balance detection and the period 0/55/56 handling are mock-tested only: the live test office has no closed year, so it is not live-verified that the browse returns periods 0, 55 and 56 as ordinary period numbers. Each request makes one GetYears call, up to four GetPeriods calls, up to three opening-balance probes (the three most recent years) and the browse itself, so at most nine Twinfield calls (limit 25); the probes cannot be merged into one call without reading three years of rows, so the cost is accepted. If Twinfield rejects the `YYYY/00` lower bound (not live-verified), the request returns a `502`; the browse is never started at period 1 instead, because that would drop the earliest year's opening balance without any signal. Provisional and final transactions are both included. Accounts with a zero balance are left out, the office is the connection's office or `x-apideck-company-id`, `currency` is null, `filter[accounting_method]=cash` returns a `400`, a date outside every fiscal period returns a `400`, and an office without fiscal years returns a `422`. A report whose debits and credits do not balance returns a `502`.
## GET /accounting/tracking-categories

`status` is not returned by this list call — the underlying vendor list only returns code
and name per row. Fetch the tracking category by id to read it.

## GET /accounting/bill-payments

Twinfield returns every matching bill payment in a single call for this endpoint — there
is no server-side pagination, so a large date range can be a large response.

`account` and `allocations` are not populated on the list. Fetch the individual bill
payment by `id` to get those fields.

## POST /accounting/bill-payments

Disabled as of 2026-08-18. Same underlying constraint as the `payments` resource: Twinfield's
bank transaction requires a reference to an existing, already-imported bank statement line,
which this connector has no way to discover (its bank-statement-import path is itself
disabled), and `account.id` needs a real ledger-account code rather than the id
`/accounting/bank-accounts` returns. See the `payments` resource's gotcha for details.

## GET /accounting/expenses

This connector fetches every matching expense in a single call — there is no server-side
pagination, so a large date range can be a large response.

`transaction_date` is approximated as the first day of the expense's accounting month, not
the actual transaction date. `sub_total`, `total_tax` and `updated_at` are not available on
the list.

This list also returns records created through this connector's `journal-entries` resource
— there is no field to tell the two apart. Treat any entry whose lines look like a
multi-line journal posting as a possible `journal-entries` record.

## POST /accounting/expenses

`account_id` (or `account.id`) is required for this connector even though the unified
schema only requires one of them via `x-apideck-strict-any-of` — Twinfield has no implicit
default for the credited side.

Not every ledger account works as `account_id` — Twinfield rejects some accounts with an
error naming a conflicting bank/cash book or control account rather than the field itself.
A plain, general-purpose ledger account works; a bank, cash-book-linked, or control
account does not. There is no way to predict which accounts qualify from the unified
request alone.

An expense posted through this connector is indistinguishable from a journal entry on the
Twinfield side; there is no `payment_type`/cash-vs-card distinction preserved.

The write always creates a draft transaction; this connector has no way to post it
directly.

`delete` is disabled as of 2026-08-18. Transaction numbers are only unique within the
specific ledger they belong to, and this operation has no way to confirm the id it is given
actually belongs to an expense rather than a `journal-entries` record or a transaction
entered directly in Twinfield's own UI — deleting one by id risks voiding an unrelated
record that happens to share its number. `get` has the same collision on the read side —
it is non-destructive but can silently return a hand-entered journal entry reshaped as an
expense (dropping any line beyond the first debit/credit pair). Do not rely on an id
obtained from `journal-entries` or Twinfield's own UI being safe to pass to this resource,
or vice versa.

`memo` becomes the credited line's description, and Twinfield caps a transaction line's
description at 40 characters ("De omschrijving mag maximaal 40 tekens bevatten") — a
longer value is rejected outright rather than truncated. This connector does not truncate
`memo` or `line_items[].description` before sending; a value over 40 characters fails the
whole write.

`update` is not implemented, and not just for lack of time: Twinfield's transaction API has no
in-place update verb, only `add` (always assigns a new transaction number) and `delete`. Faking an
update as delete-then-recreate would return a different `id` than the one being updated, breaking
the update contract, so it is left unsupported rather than built as a lossy workaround.

## POST /accounting/bank-feed-statements

Only `create` is supported. `update` and `delete` are not implemented and are not possible —
once a bank statement is created in Twinfield, it cannot be changed.

There is no endpoint to list bank feed accounts, so `bank_feed_account_id` must be supplied
by the caller as the target account's IBAN, with no way to validate it beforehand.

The statement currency defaults to `EUR` when not set on the connection — the unified model
carries no per-statement or per-transaction currency field to source it from instead.
Statements for a non-EUR bank account must have the connection's currency setting configured
accordingly.

`source_transaction_id` on read is a synthesized composite value (not a Twinfield-native id),
since Twinfield's statement transaction lines carry no identifier of their own.

Reading a bank feed statement by id (`bank-feed-statements.one`) is currently non-functional —
every request fails with a generic server error regardless of request shape. Treat this
operation as unsupported until further notice.

## GET /accounting/projects

`status` is not returned by this list call — the underlying vendor list only returns code
and name per row. Fetch the project by id to read it.

## GET /accounting/payment-methods

The only source for payment types returns just `code` and `name` — there is no field for
the payment method's type (cash/check/bank_transfer/etc.) or its active/inactive status.
Both are always `null`.

`name` is read from a Dutch-language field key. Verified only against a Dutch-language
office; a non-Dutch office may return this field empty.

## POST /accounting/payment-methods

Payment methods cannot be created through this connector — the vendor operation for
maintaining payment types is not publicly documented in enough detail to implement
reliably, unlike the read path this connector uses.

## GET /accounting/payment-methods/{id}

The only source for payment types returns just `code` and `name` — there is no field for
the payment method's type (cash/check/bank_transfer/etc.) or its active/inactive status.
Both are always `null`.

## PATCH /accounting/payment-methods/{id}

Payment methods cannot be updated through this connector — the vendor operation for
maintaining payment types is not publicly documented in enough detail to implement
reliably, unlike the read path this connector uses.

## DELETE /accounting/payment-methods/{id}

Payment methods cannot be deleted through this connector — no documented delete
mechanism exists for this data.


[API reference](https://developers.apideck.com/apis/accounting/reference.md) · [Coverage matrix](https://developers.apideck.com/apis/accounting/coverage)
