Xero – Configuration Guide

Service ID: xero

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 Xero ManualJournals (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 Xero Journals (Advanced tier from 2026-03-02 onwards).

This migration affects only the Xero connector.

Migration timeline

StageDateWhat changes
Stage 1 — Coexistence2026-06-08general-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 flip2026-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 cutover2026-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 usageWhat to do
GET /accounting/journal-entries (no filter) on XeroMigrate 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 XeroMigrate to GET /accounting/general-ledger-transactions (status is implicit there — every record is posted).
GET /accounting/journal-entries?filter[status]=draft on XeroNo change — keep using journal-entries.
POST /accounting/journal-entries or PATCH /accounting/journal-entries/{id} on XeroNo 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.read scope they were granted, so journal-entries (no filter) keeps returning the full GL. New connections created from Stage 1 onward no longer request accounting.journals.read by default (it gates Xero's paid Journals API). For a new connection, a bare GET /accounting/journal-entries (no filter) therefore returns 403 until the scope is enabled. New integrations should adopt the target model from day one: read the general ledger via general-ledger-transactions (enable accounting.journals.read + Xero Advanced tier) and read manual journals via journal-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:

curl 'https://unify.apideck.com/accounting/general-ledger-transactions' \
  -H 'Authorization: Bearer <APIDECK_API_KEY>' \
  -H 'x-apideck-app-id: <APP_ID>' \
  -H 'x-apideck-consumer-id: <CONSUMER_ID>' \
  -H 'x-apideck-service-id: xero'

Square brackets in filters (e.g. ?filter[scope]=manual) must be URL-encoded, or sent with curl --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 via meta.cursors.next.
  • GET /accounting/general-ledger-transactions/{id} — single by JournalID.
  • 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 to other.

Required scope (opt-in). general-ledger-transactions is backed by Xero's Journals API, which needs the accounting.journals.read OAuth scope and the Xero Advanced app tier (from 2026-03-02). Because it is a paid-tier scope, Apideck no longer requests accounting.journals.read by 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 through journal-entries?filter[scope]=manual, which only needs accounting.manualjournals (granted by default). Existing connections that already consented to accounting.journals.read keep 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: postedid is the JournalID; manual_journal_id is the ManualJournalID (different values):

{
  "status_code": 201,
  "status": "Created",
  "service": "xero",
  "resource": "journal-entries",
  "operation": "add",
  "data": {
    "id": "1b19e912-180c-4b5f-8e78-72fb59029459",
    "manual_journal_id": "1bb4e49a-aa71-4e8c-9588-f5b5456c65ea"
  }
}

POST /accounting/journal-entries with status: draftid and manual_journal_id are both the ManualJournalID (the journal hasn't been posted to the GL yet, so there is no JournalID):

{
  "status_code": 201,
  "status": "Created",
  "service": "xero",
  "resource": "journal-entries",
  "operation": "add",
  "data": {
    "id": "9a08fb9c-7655-42fc-a422-970748023864",
    "manual_journal_id": "9a08fb9c-7655-42fc-a422-970748023864"
  }
}

The two ids:

  • id — current behaviour, unchanged from before stage 1. After a posted POST or a draft → posted PATCH this is Xero's JournalID (general-ledger-side); for drafts it equals the ManualJournalID.
  • manual_journal_id — Xero's ManualJournalID. 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}.

If you persist the response id in your database, switch to the new field with a fallback:

const stableId = response.data.manual_journal_id ?? response.data.id;

This pattern works:

  • Today (stage 1): manual_journal_id is populated, your code uses it. Your stored id is stable across draft → posted transitions.
  • After stage 3: manual_journal_id is removed, id is permanently the ManualJournalID (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 system value reads from Xero Journals API, 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 id is a different value. Under ?filter[scope]=manual the id is Xero's ManualJournalID; under ?filter[scope]=system (and in general-ledger-transactions) the same posting carries its JournalID instead — the two are unrelated GUIDs. Ids are not comparable across scopes: a ManualJournalID you stored from a manual list will not be found in a system list, and vice versa. GET /accounting/journal-entries/{id} resolves either (the read bridge tries ManualJournals first, then Journals), so lookups keep working — but don't join or dedupe the two lists by id. To correlate a manual journal across both views, persist the manual_journal_id returned on writes.
  • Line order is different. Xero's ManualJournals returns lines in the order the bookkeeper entered them; Journals returns them ordered by Xero-internal criteria. The same posting will have its lines in a different position. If your code reads line_items[0], switch to matching by ledger_account.code, description, or id.
  • number is not populated under ?filter[scope]=manual. Xero's JournalNumber is exclusive to the Journals endpoint. If you need it, use ?filter[scope]=system (until stage 3) or read from general-ledger-transactions.
  • created_at is not populated under ?filter[scope]=manual (ManualJournals only exposes UpdatedDateUTC, mapped to updated_at).
  • ledger_account.name and tax_rate.name are not populated under ?filter[scope]=manual. Xero's ManualJournals lines only return account / tax codes, not names. The code and id fields are populated normally — resolve names client-side via accounting/ledger-accounts/{id} if you need them.
  • line_items[].id is not populated under ?filter[scope]=manual. Xero's ManualJournals lines do not expose a per-line id (JournalLineID exists only on Journals). If your code diffs or upserts by line id, match by ledger_account.code + description + type instead.

A record returned by GET /accounting/journal-entries?filter[scope]=manual (illustrative values):

{
  "id": "6b03c382-0bb6-40dd-9abe-9e055b584a08",
  "title": "Accrual — March rent",
  "number": null,
  "created_at": null,
  "updated_at": "2026-06-08T15:34:32.000Z",
  "status": "posted",
  "line_items": [
    { "type": "debit",  "total_amount": 150, "id": null, "ledger_account": { "code": "404", "name": null }, "tax_rate": { "name": null } },
    { "type": "credit", "total_amount": 150, "id": null, "ledger_account": { "code": "429", "name": null }, "tax_rate": { "name": null } }
  ]
}

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 2After 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:

  1. Migrate to general-ledger-transactions — recommended.
  2. Add ?filter[scope]=system to 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 returns 400 UnsupportedFiltersError.
  • manual_journal_id is no longer returned in write responses. (id is now permanently the ManualJournalID.)
  • The legacy Journals fallback in single reads is removed. Lookups by JournalID against journal-entries/{id} return 404 with a hint pointing at general-ledger-transactions/{id}.
  • journal-entries reads exclusively from ManualJournals. 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:

journal-entriesgeneral-ledger-transactionsNote
ididSame value — the Xero JournalID.
downstream_id(removed)Was always equal to id on Xero.
titlereferenceSame source field (Xero Reference); renamed for consistency with Invoice.reference, Bill.reference, Payment.reference, etc.
numbernumberSame — Xero JournalNumber.
posted_atposted_atSame — 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_ratecurrency, currency_rateBoth endpoints leave these null on Xero.
created_at, updated_at, created_by, updated_bysameMostly null on Xero; preserved for cross-connector consistency.
custom_fields, custom_mappings, pass_throughsameStandard Apideck fields.

Line items

JournalEntry.line_items[].…GeneralLedgerTransaction.line_items[].…Note
ididSame — Xero JournalLineID.
descriptiondescriptionSame.
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_amountSigned: positive = debit, negative = credit. Always tax-exclusive. Maps 1:1 to Xero NetAmount.
type (debit / credit)typeSame.
tax_amounttax_amountSame. 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_ratetax_rateSame.
tracking_categoriestracking_categoriesSame.
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)

{
  "id": "006d2b90-1d03-4106-a381-0157559446fa",
  "title": "P/O 9711",
  "number": "350",
  "posted_at": "2025-01-15T00:00:00.000Z",
  "status": "posted",
  "source_type": null,
  "source_id": null,
  "line_items": [
    { "type": "credit", "total_amount": -20.63,  "sub_total": -20.63, "tax_amount": 0,      "ledger_account": { "name": "Sales Tax" } },
    { "type": "credit", "total_amount": -270.63, "sub_total": -250,   "tax_amount": -20.63, "ledger_account": { "name": "Sales" } },
    { "type": "debit",  "total_amount": 270.63,  "sub_total": 270.63, "tax_amount": 0,      "ledger_account": { "name": "Accounts Receivable" } }
  ]
}

GET /accounting/general-ledger-transactions/{id} (new)

{
  "id": "006d2b90-1d03-4106-a381-0157559446fa",
  "reference": "P/O 9711",
  "number": "350",
  "posted_at": "2025-01-15T00:00:00.000Z",
  "source_type": null,
  "source_id": null,
  "line_items": [
    { "type": "debit",  "net_amount":  270.63, "tax_amount": 0,      "ledger_account": { "name": "Accounts Receivable" } },
    { "type": "credit", "net_amount": -250,    "tax_amount": -20.63, "ledger_account": { "name": "Sales" } },
    { "type": "credit", "net_amount": -20.63,  "tax_amount": 0,      "ledger_account": { "name": "Sales Tax" } }
  ]
}

GET /accounting/general-ledger-transactions?filter[source_id]={id} (list)

The list endpoint exposes the discriminated origin:

{
  "id": "006d2b90-1d03-4106-a381-0157559446fa",
  "source_type": "invoice",
  "source_id": "bd63b5f1-5503-4c62-9c4a-e4b9e4257dc9",
  "reference": "P/O 9711",
  "number": "350",
  "line_items": [...]
}

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:

FilterBehavior
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 ?? id pattern 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-entries returns only manual journals.
  • Update your data model:
    • titlereference
    • total_amount + sub_totalnet_amount (always tax-exclusive, always signed)
    • Drop handling for: status, tax_inclusive, accounting_period, header tracking_categories, downstream_id, line-level customer / supplier / employee / department_id / location_id
  • Start consuming source_type and source_id — these were always null on 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, or id. Line order differs between Xero Journals and ManualJournals, so line_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.read scope only where GL reads are needed. It is no longer requested by default (it gates the Advanced-tier Journals API). Without it, general-ledger-transactions and the legacy full-GL journal-entries read (no filter / ?filter[scope]=system) return 403; journal-entries?filter[scope]=manual keeps working on any tier.
  • Existing connections are unaffected — those that already consented to accounting.journals.read keep 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]=manual to preview the post-flip behaviour. Cover the items listed in Differences between ?filter[scope]=manual and ?filter[scope]=system — the id changing to the ManualJournalID, line order, missing number, created_at, and ledger-account / tax-rate names.
  • Verify your pagination logic walks meta.cursors.next until null on every endpoint you call.

Pre-Stage-3 checks

  • Remove any ?filter[scope]=system still in your code — Stage 3 returns 400 UnsupportedFiltersError on it.
  • The manual_journal_id ?? id fallback continues to work after Stage 3: id becomes permanently the ManualJournalID, so the ?? resolves to it directly.