Xero – Configuration Guide
Get paid sooner when you accept payments online straight from a Xero invoice. Give your customers different payment options, including PayPal and Stripe.
Migrating from journal-entries to general-ledger-transactions (Xero)
Effective 2026-03-02 Xero moves the Journals API behind the Advanced app tier. To keep your integration on free Xero tiers wherever possible, and to make the read scope explicit, Apideck is splitting the legacy accounting/journal-entries resource into two distinct resources on Xero:
accounting/journal-entries— will be redefined to manual journals only, backed by XeroManualJournals(free in every tier). Full create / read / update support.accounting/general-ledger-transactions— new read-only resource exposing the full general ledger view (invoices, bills, payments, payroll, manual journals, and every other posting). Backed by XeroJournals(Advanced tier from 2026-03-02 onwards).
This migration affects only the Xero connector.
Migration timeline
| Stage | Date | What changes |
|---|---|---|
| Stage 1 — Coexistence | 2026-06-08 | general-ledger-transactions ships. journal-entries gains migration tooling: a new manual_journal_id field on writes, and a ?filter[scope]=manual|system query parameter on reads. Default behaviour of journal-entries is unchanged for existing connections (see the note on new connections below). |
| Stage 2 — Default flip | 2026-07-08 (~30 days after Stage 1) | The default read source on journal-entries flips to ManualJournals. Customers who haven't migrated yet can use ?filter[scope]=system as a one-line hotfix to keep the previous behaviour. |
| Stage 3 — Hard cutover | 2026-08-07 (~30 days after Stage 2) | The ?filter[scope] parameter and the manual_journal_id field are removed. journal-entries is exclusively manual on Xero. general-ledger-transactions is the only path to the full general ledger view. |
You have until 2026-08-07 (60 days from Stage 1) to migrate fully. The intermediate Stage 2 (2026-07-08) is your last opportunity to discover any blocker without breaking changes.
Who needs to migrate
| Current usage | What to do |
|---|---|
GET /accounting/journal-entries (no filter) on Xero | Migrate to GET /accounting/general-ledger-transactions if you need the full GL view; otherwise nothing to do (you'll get manual journals only after stage 2). |
GET /accounting/journal-entries?filter[status]=posted on Xero | Migrate to GET /accounting/general-ledger-transactions (status is implicit there — every record is posted). |
GET /accounting/journal-entries?filter[status]=draft on Xero | No change — keep using journal-entries. |
POST /accounting/journal-entries or PATCH /accounting/journal-entries/{id} on Xero | No change — manual journal create / update stays here. Optionally adopt the new manual_journal_id field for stable id handling. |
Existing vs new connections. The "unchanged default" above applies to connections that already exist — they keep the
accounting.journals.readscope they were granted, sojournal-entries(no filter) keeps returning the full GL. New connections created from Stage 1 onward no longer requestaccounting.journals.readby default (it gates Xero's paid Journals API). For a new connection, a bareGET /accounting/journal-entries(no filter) therefore returns403until the scope is enabled. New integrations should adopt the target model from day one: read the general ledger viageneral-ledger-transactions(enableaccounting.journals.read+ Xero Advanced tier) and read manual journals viajournal-entries?filter[scope]=manual(free, no extra scope).
Stage 1 — What's available now
This is what you can use today, additively, without breaking anything in your integration.
The shorthand
GET /accounting/...used throughout this guide omits the standard Apideck headers for brevity. A complete request looks like:Square brackets in filters (e.g.
?filter[scope]=manual) must be URL-encoded, or sent withcurl --globoff.
1. New resource: accounting/general-ledger-transactions
Read-only view of every posting to the general ledger, with each entry discriminated by origin.
GET /accounting/general-ledger-transactions— list, cursor pagination viameta.cursors.next.GET /accounting/general-ledger-transactions/{id}— single byJournalID.- Filters:
source_type,source_id,updated_since. - Source-type enum:
other(catch-all),invoice,bill,credit_note,payment,refund,expense,journal_entry,payroll. On Xero, postings that don't fit one of the named values (cash spend / receive money, transfers, bank-rule auto-codings, prepayments, overpayments) map toother.
Required scope (opt-in).
general-ledger-transactionsis backed by Xero'sJournalsAPI, which needs theaccounting.journals.readOAuth scope and the Xero Advanced app tier (from 2026-03-02). Because it is a paid-tier scope, Apideck no longer requestsaccounting.journals.readby default — enable it explicitly for the connections that need GL reads (Vault → connector settings → scopes, or via the connector's custom scopes). Connections without it can still read manual journals throughjournal-entries?filter[scope]=manual, which only needsaccounting.manualjournals(granted by default). Existing connections that already consented toaccounting.journals.readkeep it until they re-authorize.
2. New write field: manual_journal_id
POST and PATCH responses on Xero journal-entries now return a manual_journal_id alongside the existing id. Two example responses (illustrative ids):
POST /accounting/journal-entries with status: posted — id is the JournalID; manual_journal_id is the ManualJournalID (different values):
POST /accounting/journal-entries with status: draft — id and manual_journal_id are both the ManualJournalID (the journal hasn't been posted to the GL yet, so there is no JournalID):
The two ids:
id— current behaviour, unchanged from before stage 1. After a posted POST or a draft → posted PATCH this is Xero'sJournalID(general-ledger-side); for drafts it equals theManualJournalID.manual_journal_id— Xero'sManualJournalID. Stable across the lifecycle (draft → posted → voided). Always returned, in any state.
The response data object contains only these two fields — Apideck does not echo back the rest of the journal entry's body. To read the full entity after writing, call GET /accounting/journal-entries/{id} or GET /accounting/general-ledger-transactions/{id}.
Recommended pattern for migration
If you persist the response id in your database, switch to the new field with a fallback:
This pattern works:
- Today (stage 1):
manual_journal_idis populated, your code uses it. Your stored id is stable across draft → posted transitions. - After stage 3:
manual_journal_idis removed,idis permanently theManualJournalID(the previous flip on draft → posted is gone). The??fallback keeps your code working without changes.
3. New read parameter: ?filter[scope]=manual|system
A new query parameter on GET /accounting/journal-entries lets you preview the post-stage-2 behaviour:
GET /accounting/journal-entries?filter[scope]=manual ← reads ManualJournals only (target behaviour)
GET /accounting/journal-entries?filter[scope]=system ← reads Journals (current default, full GL)
GET /accounting/journal-entries ← same as filter[scope]=system in stage 1
Use ?filter[scope]=manual to test your code against the post-cutover behaviour without waiting for the default flip.
Note: the
systemvalue reads from XeroJournalsAPI, which returns the full general ledger view — invoices, bills, payments, manual journals posted, etc. Despite the name, it is not "system-only"; it is "everything in the ledger". This is the same behaviour as omitting the filter.
Differences between ?filter[scope]=manual and ?filter[scope]=system
The two scopes hit different Xero endpoints (ManualJournals and Journals respectively), and Xero exposes slightly different shapes on each. When you migrate from the legacy default (effectively system) to manual, expect:
- The top-level
idis a different value. Under?filter[scope]=manualtheidis Xero'sManualJournalID; under?filter[scope]=system(and ingeneral-ledger-transactions) the same posting carries itsJournalIDinstead — the two are unrelated GUIDs. Ids are not comparable across scopes: aManualJournalIDyou stored from amanuallist will not be found in asystemlist, and vice versa.GET /accounting/journal-entries/{id}resolves either (the read bridge triesManualJournalsfirst, thenJournals), so lookups keep working — but don't join or dedupe the two lists byid. To correlate a manual journal across both views, persist themanual_journal_idreturned on writes. - Line order is different. Xero's
ManualJournalsreturns lines in the order the bookkeeper entered them;Journalsreturns them ordered by Xero-internal criteria. The same posting will have its lines in a different position. If your code readsline_items[0], switch to matching byledger_account.code,description, orid. numberis not populated under?filter[scope]=manual. Xero'sJournalNumberis exclusive to theJournalsendpoint. If you need it, use?filter[scope]=system(until stage 3) or read fromgeneral-ledger-transactions.created_atis not populated under?filter[scope]=manual(ManualJournalsonly exposesUpdatedDateUTC, mapped toupdated_at).ledger_account.nameandtax_rate.nameare not populated under?filter[scope]=manual. Xero'sManualJournalslines only return account / tax codes, not names. Thecodeandidfields are populated normally — resolve names client-side viaaccounting/ledger-accounts/{id}if you need them.line_items[].idis not populated under?filter[scope]=manual. Xero'sManualJournalslines do not expose a per-line id (JournalLineIDexists only onJournals). If your code diffs or upserts by line id, match byledger_account.code+description+typeinstead.
A record returned by GET /accounting/journal-entries?filter[scope]=manual (illustrative values):
Note id is the ManualJournalID (not the JournalID you'd see under ?filter[scope]=system), and number / created_at / the name fields / line_items[].id are all null — as described above.
4. Read bridge improvement
GET /accounting/journal-entries/{id} now tries ManualJournals/{id} first and falls back to Journals/{id} only if the id is not a manual journal. This reduces calls to the paid endpoint when you're looking up entries by ManualJournalID. Behaviour for callers is unchanged.
Stage 2 — Default flip (2026-07-08)
The default read source on journal-entries flips from Journals to ManualJournals.
| Before stage 2 | After stage 2 |
|---|---|
GET /journal-entries (no filter) → reads Journals (full GL) | GET /journal-entries (no filter) → reads ManualJournals (manual only) |
Everything else stays the same: ?filter[scope]=manual keeps reading manuals; ?filter[scope]=system keeps reading Journals; writes are unchanged; manual_journal_id is still returned.
If your code expected the legacy default (full GL view via journal-entries), you have two options:
- Migrate to
general-ledger-transactions— recommended. - Add
?filter[scope]=systemto your existing requests as a hotfix. Works until stage 3.
Stage 3 — Hard cutover (2026-08-07)
The temporary scaffolding is removed:
?filter[scope]is no longer accepted. Sending it returns400 UnsupportedFiltersError.manual_journal_idis no longer returned in write responses. (idis now permanently theManualJournalID.)- The legacy
Journalsfallback in single reads is removed. Lookups byJournalIDagainstjournal-entries/{id}return404with a hint pointing atgeneral-ledger-transactions/{id}. journal-entriesreads exclusively fromManualJournals. There is no escape hatch.
Code using the recommended manual_journal_id ?? id pattern keeps working without changes.
Field-by-field mapping (read operations)
What you got from GET /accounting/journal-entries/{id} you now get from GET /accounting/general-ledger-transactions/{id} with the following changes:
Header
journal-entries | general-ledger-transactions | Note |
|---|---|---|
id | id | Same value — the Xero JournalID. |
downstream_id | (removed) | Was always equal to id on Xero. |
title | reference | Same source field (Xero Reference); renamed for consistency with Invoice.reference, Bill.reference, Payment.reference, etc. |
number | number | Same — Xero JournalNumber. |
posted_at | posted_at | Same — Xero JournalDate. |
status | (removed) | Always "posted" in Journals. Status remains meaningful only on journal-entries (manual-only) where draft / posted matters. |
source_type (string, never populated) | source_type (enum, populated in list responses) | Now a typed enum with 9 values (see Stage 1 above). |
source_id (never populated) | source_id (populated in list responses) | The id of the originating document — fetch it with GET /accounting/{resource}/{source_id}. |
tax_inclusive | (removed) | Never populated by Xero — derivable per line. |
accounting_period | (removed) | Derivable from posted_at. |
tracking_categories (header) | (removed) | Always at line level on Xero. |
currency, currency_rate | currency, currency_rate | Both endpoints leave these null on Xero. |
created_at, updated_at, created_by, updated_by | same | Mostly null on Xero; preserved for cross-connector consistency. |
custom_fields, custom_mappings, pass_through | same | Standard Apideck fields. |
Line items
JournalEntry.line_items[].… | GeneralLedgerTransaction.line_items[].… | Note |
|---|---|---|
id | id | Same — Xero JournalLineID. |
description | description | Same. |
total_amount (signed, sometimes tax-inclusive) | (removed) | Replaced by net_amount. |
sub_total ("normally before tax") | (removed) | In GL view always equals net_amount. |
| (none) | net_amount | Signed: positive = debit, negative = credit. Always tax-exclusive. Maps 1:1 to Xero NetAmount. |
type (debit / credit) | type | Same. |
tax_amount | tax_amount | Same. Non-zero on the income / expense line of a tax-bearing posting (e.g. the Sales or Bank Fees line); 0 on offsetting AR / AP / dedicated Tax Payable lines. See the example below. |
tax_rate | tax_rate | Same. |
tracking_categories | tracking_categories | Same. |
customer, supplier, employee | (removed) | Derivable via source_id + source_type. |
department_id, location_id | (removed) | Covered by tracking_categories. |
Example — same invoice posting from both endpoints
The same GL posting (JournalID = 006d2b90-1d03-4106-a381-0157559446fa, a sales invoice with three lines: DR Accounts Receivable, CR Sales, CR Sales Tax) seen from both endpoints:
GET /accounting/journal-entries/{id} (legacy)
GET /accounting/general-ledger-transactions/{id} (new)
GET /accounting/general-ledger-transactions?filter[source_id]={id} (list)
The list endpoint exposes the discriminated origin:
You can now fetch the source invoice with GET /accounting/invoices/bd63b5f1-5503-4c62-9c4a-e4b9e4257dc9.
Filters and operations on general-ledger-transactions
List endpoint
GET /accounting/general-ledger-transactions supports these filters:
| Filter | Behavior |
|---|---|
filter[source_type]=invoice|bill|payment|… | Returns only postings originated from the given document type. Multi-value source types (e.g. payment matches both A/R and A/P payments) are combined under the hood. |
filter[source_id]={guid} | Reverse lookup — find every GL posting that originated from a given source document. |
filter[updated_since]={iso-datetime} | Returns only records updated on or after the given timestamp. |
Sort is not supported (Xero returns entries ordered by JournalNumber ascending).
Pagination is cursor-based via meta.cursors.next. Iterate until next is null to retrieve the full set.
Single endpoint
GET /accounting/general-ledger-transactions/{id} — id is the stable JournalID.
Write operations
general-ledger-transactions is read-only. To write, use the originating unified resource:
- Manual journal create / update →
POST/PATCH /accounting/journal-entries - Invoice create / update →
POST/PATCH /accounting/invoices - Bill create / update →
POST/PATCH /accounting/bills - Payment create →
POST /accounting/payments
Anything written through these resources appears automatically in general-ledger-transactions once Xero posts it to the GL.
Known Xero limitations
These are properties of Xero's Journals API itself.
source_type and source_id are missing on the single endpoint
Xero's GET /api.xro/2.0/Journals/{id} does not return SourceType or SourceID for any journal — invoices, bills, payments, manual journals, all stripped. Only the list endpoint includes them.
Impact: GET /accounting/general-ledger-transactions/{id} returns source_type: null and source_id: null on Xero. To get those fields, use the list endpoint with a filter (e.g. ?filter[source_id]={your-stored-id}). Note that filter[source_id] takes the originating document id (e.g. the invoice's id), not the transaction's own id — to locate a specific transaction by its own id, narrow the list with filter[updated_since] close to its posted_at and iterate.
?filter[source_type]=journal_entry returns no results
Xero's underlying filter for manual-journal postings does not return any rows even when manual-journal entries clearly exist in the unfiltered list response.
Impact: filtering for manual-journal postings via general-ledger-transactions does not work on Xero. To list manual journals, use GET /accounting/journal-entries?filter[scope]=manual (or just journal-entries after stage 2).
Currency information is not exposed on Journals
Xero's Journals endpoint does not return currency or exchange-rate fields. Both journal-entries (legacy default) and general-ledger-transactions leave currency and currency_rate as null on Xero. To get currency for a posting, fetch the originating document via source_type + source_id.
Migration checklist
Most callers fall into one of three patterns. Find yours and follow only the items that apply. Everything below should be in place before Stage 3 (2026-08-07) closes the migration window.
If you write to journal-entries (POST / PATCH)
- Use the
manual_journal_id ?? idpattern when persisting the response id. It works today and survives the Stage 3 cleanup unchanged.
If you read the full GL view via journal-entries
(no filter, or ?filter[status]=posted)
- Migrate read calls to
accounting/general-ledger-transactions. After Stage 2,journal-entriesreturns only manual journals. - Update your data model:
title→referencetotal_amount+sub_total→net_amount(always tax-exclusive, always signed)- Drop handling for:
status,tax_inclusive,accounting_period, headertracking_categories,downstream_id, line-levelcustomer/supplier/employee/department_id/location_id
- Start consuming
source_typeandsource_id— these were alwaysnullon the legacy resource.
If you read drafts via ?filter[status]=draft
- No change required.
Anyone reading line_items by index
- Refactor to match by
ledger_account.code,description, orid. Line order differs between XeroJournalsandManualJournals, soline_items[0]may be a different line after the default flip in Stage 2.
Tier decision (admin)
From 2026-03-02 the underlying Xero Journals endpoint requires the Xero Advanced tier. Decide which connections need it for GL reads via general-ledger-transactions. Connections that only need manual journal create / update or journal-entries reads can stay on any lower tier.
- Enable the
accounting.journals.readscope only where GL reads are needed. It is no longer requested by default (it gates the Advanced-tierJournalsAPI). Without it,general-ledger-transactionsand the legacy full-GLjournal-entriesread (no filter /?filter[scope]=system) return403;journal-entries?filter[scope]=manualkeeps working on any tier. - Existing connections are unaffected — those that already consented to
accounting.journals.readkeep it until they re-authorize. Only new connections (and full re-authorizations) pick up the reduced default scope set.
Pre-Stage-2 checks
- Test your code against
?filter[scope]=manualto preview the post-flip behaviour. Cover the items listed in Differences between?filter[scope]=manualand?filter[scope]=system— theidchanging to theManualJournalID, line order, missingnumber,created_at, and ledger-account / tax-rate names. - Verify your pagination logic walks
meta.cursors.nextuntilnullon every endpoint you call.
Pre-Stage-3 checks
- Remove any
?filter[scope]=systemstill in your code — Stage 3 returns400 UnsupportedFiltersErroron it. - The
manual_journal_id ?? idfallback continues to work after Stage 3:idbecomes permanently theManualJournalID, so the??resolves to it directly.
Related Apideck resources
accounting/journal-entries— manual journals (full create / read / update on Xero)accounting/general-ledger-transactions— full general ledger view (read-only)accounting/invoices,accounting/bills,accounting/payments— for write paths