Building an accounting integration

Connect Spendesk to an ERP or accounting system: keep its chart of accounts in Spendesk, post every prepared expense and payment into it, and tell Spendesk what was posted.

This guide walks you through building a two-way integration between Spendesk and an ERP or accounting system, using the Spendesk API. It covers which endpoints to use, in which order, and the three flows your integration needs.

Examples use a generic ERP vocabulary, with NetSuite named in brackets as a concrete illustration — for instance "the supplier invoice (a Vendor Bill in NetSuite)".

📘

Spendesk already integrates with some ERPs

Spendesk offers native integrations with several accounting systems, NetSuite included. This guide is for building your own: for an ERP without a native integration, or for needs a native integration does not cover. A company uses one or the other for a given ERP — see Before you start.

How it works

An accounting integration runs three flows:

#FlowDirectionWhat moves
1Reference dataERP → Spendeskchart of accounts, suppliers, analytical dimensions — so expenses are coded with the ERP's own values
2Accounting entriesSpendesk → ERPevery prepared payable (invoice, credit note, card purchase, expense claim) and every settlement (payment)
3StatusERP → Spendeskwhat was posted in the ERP

In Spendesk, finance teams review and prepare each payable — they check its accounts, VAT and analytical fields. A prepared payable is ready for export: your integration posts it into the ERP, then marks it exported. That status is what keeps the same entry from being exported twice.

Start with the flow your customers use most — usually supplier invoices and their payments — then add card spend and expense claims: the loop is the same for every type.

Before you start

Credentials. Customers building for their own company use an API key; partners building for many companies use OAuth2 — see How to Authenticate. For groups with several entities, one organisation-level credential covers every entity — see Organisation-level access.

Test environment. Build against the sandbox, https://public-api.demo.spendesk.com, before going live on https://public-api.spendesk.com.

Scopes. Request the scopes of the flows you build — see Scopes:

FlowScopes
1 — Reference dataexperimental:chart-of-accounts:read and :write, supplier:read, experimental:supplier:manage, experimental:analytical-field-v2:read and :write, experimental:expense-category-v2:read and :write, cost-center:read and cost-center:manage, user:read, experimental:external-reference:read and :write
2 — Accounting entriespayable:read, experimental:payable-search:read, payable-attachment:read, settlement:read, bank-fee:read, wallet-load:read, experimental:webhooks:read and :write
3 — Statusexperimental:accounting:update, experimental:accounting-export:read

Check this first: no native integration. If a company's accounting is connected through a Spendesk native integration, that integration owns the export and the mappings end to end. Your integration is for companies whose accounting setup in Spendesk is not a native integration.

API export or journal files? This guide posts entries into the ERP through its API. If the ERP imports files instead, Spendesk can generate the purchase and bank journals for you — see Exporting accounting data.

Flow 1 — Keep reference data in sync (ERP → Spendesk)

Spendesk users code each expense with accounts and analytical values. When those come from the ERP, every entry you export later already carries the ERP's own codes.

SpendeskGeneric ERPNetSuite exampleEndpoints
Company (entity)legal entitySubsidiaryGet Companies
Expense, bank, VAT, supplier, employee and reverse-charge accountsgeneral ledger accountsAccount (by type), Tax CodeGet accounts, Bulk update accounts, Create account
SuppliervendorVendorGet Suppliers, Create Supplier(s), Update Suppliers, Archive/Unarchive Suppliers
Supplier accountpayables sub-ledger accountVendor's payables accountAssign creditors to accounts
Analytical fields and values, cost centersdimensionsDepartment, Class, Location, custom segmentsList Analytical Fields, Create Analytical Field Value, Get Cost Centers, Create Cost Centers
Expense categoriesexpense typesExpense CategoryList Expense Categories, Create Expense Category
MemberemployeeEmployeeGet Users (read only — match on email)
Any of the abovethe ERP's own IDinternal IDExternal references

Sync in dependency order — accounts, then suppliers, then dimensions — once at connection, then on a schedule.

1. Push the chart of accounts

PUT /v1/experimental/accounts?type=<type> creates or updates the accounts of one type in bulk, where type is expense, bank, tax, supplier, employee or reverseCharge.

curl --request PUT \
     --url 'https://public-api.spendesk.com/v1/experimental/accounts?type=expense' \
     --header 'accept: application/json' \
     --header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.masked' \
     --header 'content-type: application/json' \
     --data '{
  "accounts": [
    { "name": "Travel", "code": "625100" },
    { "name": "Software subscriptions", "code": "651000" }
  ]
}'

To retire an account, archive it (isArchived: true) rather than deleting it: entries already coded on it keep their history.

2. Sync suppliers

Match each Spendesk supplier with the ERP's vendor — by VAT number or IBAN (vatNumber, iban filters of Get Suppliers), then by name. Create the suppliers that exist only in the ERP with Create Supplier(s) (up to 100 per call), and archive the ones the ERP deactivated.

When the ERP keeps one payables account per vendor, assign it with Assign creditors to accounts: accountId is the supplier account, creditorId the Spendesk supplier ID. Employee accounts, used for expense claims, are assigned the same way, with the user ID from Get Users as creditorId.

Then store the vendor's ERP ID on the Spendesk supplier, so you never have to match it again — see Store ERP IDs on Spendesk objects.

3. Sync dimensions

Mirror each ERP dimension (a Department in NetSuite, for instance) as an analytical field, and its values as the field's values, with the v2/analytical-fields endpoints. Store each value's ERP ID as an external reference (entityType: "analyticalFieldValue"): you will need it to post entries. Expense categories and cost centers follow the same pattern.

Store ERP IDs on Spendesk objects

External references store your ERP's identifiers on Spendesk objects, so your integration does not have to maintain its own mapping table. Each reference links one Spendesk object to its ID in the ERP (the internal ID in NetSuite), under a provider name of your choice for the ERP — keep it the same everywhere.

External references are experimental: the contract may change. Scopes: experimental:external-reference:read to look references up, experimental:external-reference:write to store them.

Supported objects

entityTypeSpendesk objectWhere its ID comes from
supplierSupplierGet Suppliers
costCenterCost centerGet Cost Centers
expenseCategoryExpense categoryGET /v1/expense-categories
analyticalFieldAnalytical fieldGET /v1/analytical-fields
analyticalFieldValueAnalytical field valueGET /v1/analytical-fields/{fieldId}/values
supplierAccountSupplier accountGet accounts with type=supplier
employeeAccountEmployee accountGet accounts with type=employee
bankAccountBank accountGet accounts with type=bank
expenseAccountExpense accountGet accounts with type=expense
taxAccountTax accountGet accounts with type=tax

Store references

Upsert external references takes a list of references; one call can mix object types.

curl --request POST \
     --url https://public-api.spendesk.com/v1/experimental/external-references \
     --header 'accept: application/json' \
     --header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.masked' \
     --header 'content-type: application/json' \
     --data '{
  "references": [
    { "entityType": "supplier", "entityId": "<supplier id>", "provider": "netsuite", "externalId": "VEND-10042" },
    { "entityType": "costCenter", "entityId": "<cost center id>", "provider": "netsuite", "externalId": "DEPT-300", "externalName": "Finance" }
  ]
}'
  • entityType, entityId, provider and externalId are required; externalName is optional and defaults to externalId.
  • Send at most 15 references per call: larger requests exceed the size limit and are refused with 413.
  • Sending the same reference again is safe: it does not create a duplicate.

Look references up

Get external references takes one entityType per call, and entityIds to find the ERP IDs of Spendesk objects, or externalIds to find the Spendesk objects of ERP IDs (comma-separated). The answer is not paginated. A reference can be read as soon as it is stored; an object without one returns {"data": []}.

curl --request GET \
     --url 'https://public-api.spendesk.com/v1/experimental/external-references?entityType=supplier&entityIds=<supplier id 1>,<supplier id 2>' \
     --header 'accept: application/json' \
     --header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.masked'
{
  "data": [
    {
      "entityType": "supplier",
      "entityId": "<supplier id 1>",
      "externalId": "VEND-10042",
      "externalName": "VEND-10042",
      "provider": "netsuite",
      "source": "public_api",
      "componentId": null,
      "updatedAt": "2026-09-28T17:56:39.203Z"
    }
  ]
}

References are kept per entityType: the same Spendesk ID used by two types of object has a separate reference for each.

Good practice

  • Check the object before you store its reference. The API does not reject every unknown entityId, so a successful answer does not prove the ID is valid: read the object first — for example with Get Supplier By Id.
  • One reference per account, several per other object. Storing a new externalId on an account replaces the previous one; on suppliers, cost centers, expense categories and analytical fields and values, references add up. References cannot be deleted yet: store one only once you are sure of the mapping, and keep test data off production companies.
  • Use analyticalField for analytical fields.
  • Send X-Company-Id with an organisation-level credential: without it, the API answers 400; with a company outside the organisation, 403.

External references cover reference data only. Payables and settlements are not covered here: their export status is set as described in Flow 3.

Flow 2 — Post accounting entries (Spendesk → ERP)

1. Know when a payable is ready

Subscribe your endpoint to two events with Create a webhook instance, once per company:

  • payables: ["prepared"] — a payable has been prepared and is ready to export;
  • settlements: ["created"] — a payment has been made.
curl --request POST \
     --url https://public-api.spendesk.com/v1/experimental/webhooks \
     --header 'accept: application/json' \
     --header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.masked' \
     --header 'content-type: application/json' \
     --data '{
  "url": "https://integration.example.com/spendesk/events",
  "events": { "payables": ["prepared"], "settlements": ["created"] }
}'

The call creates one webhook instance per event and answers with their ids and the signing secret — returned only this once: store it to verify event signatures. Each event carries the payable's or settlement's id, companyId and version — see Using webhooks for the payload, retries and signature.

Webhooks or polling? Webhooks deliver within seconds; polling is simpler to run. Use both: webhooks for speed, and a scheduled run with Search Payables as a safety net for any event you missed. Search filters on the export status — toExport covers prepared payables that were never exported:

curl --request POST \
     --url https://public-api.spendesk.com/v1/payables/search \
     --header 'accept: application/json' \
     --header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.masked' \
     --header 'content-type: application/json' \
     --data '{
  "filters": { "field": "bookkeepingStatus", "operator": "=", "value": ["toExport"] },
  "limit": 100
}'

Follow nextCursor until it is null — see Pagination. Each search covers one company — the one in X-Company-Id with an organisation-level credential: send one search per company returned by Get Companies. A snapshot of payables filtered on "bookkeepingStatus": ["prepared"] works too.

2. Read the payable

Get Payable by ID returns everything an entry needs:

  • type — which ERP record to create (see the table below);
  • companyId — which entity (a Subsidiary in NetSuite) to post into. Search results do not carry it: they all belong to the company you searched;
  • counterparty — the supplier or employee, with its payables account (accountPayable.generalAccountCode and auxiliaryAccountCode);
  • lineItems[] — for each line, the expenseAccount and the vatAccount or reverseChargeAccount (each with its id and code, the VAT account with its rate), analyticalProperties, cost center, and amounts under financial (netAmount for the net amount);
  • invoiceNumber, creditNoteNumber, referenceInvoiceNumber, payableDate, accountingDate, invoiceDueDate;
  • amount and currency, and their company-currency equivalents functionalAmount, functionalCurrency, functionalExchangeRate;
  • version — needed to update its status later.

Amounts are in the smallest unit of the currency — cents for EUR and USD.

Payable data may be cached for a short period. If the payable you read has a lower version than the event you received, read it again later rather than posting a stale copy.

Receipts and invoices come from Get Payable Attachments as short-lived URLs: download each file as soon as you post the entry, and attach it to the ERP record (a File Cabinet file in NetSuite).

3. Resolve the ERP IDs

The payable carries Spendesk IDs: its supplier (counterparty), cost center, analytical values and accounts. Turn them into ERP IDs with the references you stored in Flow 1 — one Get external references call per object type, with the IDs of that type in entityIds:

curl --request GET \
     --url 'https://public-api.spendesk.com/v1/experimental/external-references?entityType=analyticalFieldValue&entityIds=<value id 1>,<value id 2>' \
     --header 'accept: application/json' \
     --header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.masked'

If an object has no reference yet, do not post the entry: map the object first (see Store ERP IDs on Spendesk objects), then export it on the next run.

4. Create the ERP record

Payable typeGeneric ERP recordNetSuite example
invoicePurchasesupplier invoiceVendor Bill
creditNotesupplier credit note, applied to the invoice it refers toVendor Credit
singlePurchaseCard, multiUseCard, subscriptionCard, physicalCardsupplier invoice and its payment, a card charge, or a journal entry — per the customer's accounting policyVendor Bill + Vendor Payment, or Credit Card Charge
expenseClaim, mileageAllowance, perDiemAllowanceexpense reportExpense Report
reverseBill — a reversal, with a negative amounta supplier credit note, or a card refund as a credit or a journal entry — see belowVendor Credit, or Journal Entry

A reverseBill payable is money coming back to the company. It is either a supplier credit note or a card transaction reversal; Search Payables tells them apart, as a payable of kind reversal with a subType of creditNote for a credit note, or singlePurchase, physical, multiUseCard or subscription for a card reversal. To search for every reversal, filter payableType on both cardRefund and creditNote: cardRefund alone misses credit notes.

Create each record idempotently: use the Spendesk ID as the record's external ID in the ERP (NetSuite upserts on externalId), so a retried call or a duplicate event updates the same record instead of creating a second one.

5. Post payments, fees and wallet loads

SpendeskGeneric ERP recordNetSuite exampleEndpoint
Settlement — a payment from the Spendesk walletpayment applied to the invoice or expense reportVendor PaymentGet Settlements
Bank fee — FX, ATM and other feesjournal entry on the bank-fees accountJournal EntryGet Bank Fees
Wallet load — money added to the walletbank transfer into the wallet's bank accountDeposit or Journal EntryGet Wallet Loads

Each settlement lists the payables it pays in allocations[].payableId. Post a payment only once the invoice it pays exists in the ERP; otherwise leave it for the next run. How settlements, fees and payables relate is explained in What is Spend Data?.

Flow 3 — Report back to Spendesk (ERP → Spendesk)

Once the ERP has accepted an entry, tell Spendesk. Until you do, the payable stays ready for export — and a later run would export it again.

ResultCall
Payable posted in the ERPUpdate Payables bookkeeping status with exportedManually
Settlement posted in the ERPUpdate settlements state with exported
Bank fee or wallet load posted in the ERPno status to set: keep a record of the ones you posted
curl --request PUT \
     --url https://public-api.spendesk.com/v1/payables/bookkeeping-status \
     --header 'accept: application/json' \
     --header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.masked' \
     --header 'content-type: application/json' \
     --data '{
  "payables": [
    { "id": "<payable id>", "version": 3, "status": "exportedManually" }
  ]
}'

Each payable needs its current version, from Get Payable by ID. For settlements, send only those still in state created — filter Get Settlements on state — with their version, which is required but not checked today. Status updates take up to 50 items each. When only some items succeed, the response is 207 Multi-Status: check the outcome of each item — see Error Handling:

  • a payable that is no longer in a state that can be exported comes back notUpdated, with a reason such as invalidState or wrongVersion;
  • a settlement that could not be updated comes back notUpdated with reason: "notFound", whatever the cause — an unknown ID or a settlement that cannot be exported.

Keep the ERP's ID of each record you create on your side, next to the Spendesk ID: you will need it for support and reconciliation.

The API cannot mark a prepared payable as failed: failedExport is accepted only for a payable whose export is already in progress (exporting), and answers invalidState otherwise. When the ERP rejects a payable, leave it ready for export and keep the error on your side, as below.

Handling errors

Sort ERP errors into three kinds, and handle each differently:

KindExampleWhat to do
The customer can fix itclosed accounting period, vendor missing in the ERP, mandatory dimension not filledleave the payable ready for export, show the customer what to fix in your integration, and retry on the next run
TemporaryERP unavailable, timeout, rate limitedretry later
Needs youunexpected errorleave the payable ready for export, and alert your team with a support reference

Check what you can before posting — a mapped vendor, the dimensions the ERP requires, an open period — to turn most ERP rejections into clear messages.

Reconcile

Get entity export status lists the payables, settlement allocations, wallet loads and bank fees that are exported or notExported over a period of up to a year. Run it regularly to find entries still waiting and to check that Spendesk and the ERP agree.

Before going live

  • Multi-entity: loop over the companies from Get Companies, send one search per company with X-Company-Id, and post each company's entries into the matching ERP entity.
  • Idempotency: replaying an event, a retry after a lost response, or a duplicate webhook must never create a second ERP record.
  • Rate limits: 1,000 requests per minute per company and credential — see Rate Limiting. Batch your updates (50 per call) and queue ERP calls too: ERPs often allow few concurrent requests.
  • Monitoring: alert on payables waiting for export for longer than your usual cycle, and on a rising number of failed exports.
  • Receipts: download attachments at export time — their links expire.

Next steps