Integration APIs #
This document provides basic information about the available integrations in Tofu and the APIs required to activate them.
Overview #
Tofu supports two main types of integrations:
- Accounting Integrations - Connect to accounting software for invoice synchronization
- Trigger Integrations - Connect to document storage systems for automated document processing
At a minimum integrations of any type require two steps to fully integrate.
- OAuth Integration Flow
- Integration Setup
Tofu AccountClass Types #
Tofu uses a unified AccountClass enum to categorize all accounting accounts across different providers:
- Asset: Assets (current, fixed, inventory, etc.)
- Liability: Liabilities (current, non-current, payables, etc.)
- Equity: Equity accounts (share capital, retained earnings, etc.)
- Revenue: Revenue/Income accounts (sales, other income, etc.)
- Expense: Expense accounts (costs, overheads, depreciation, etc.)
- Bank: Bank accounts
- CreditCard: Credit card accounts
Available Integrations #
Xero (Accounting Integration) #
Xero is a cloud-based accounting software that allows you to sync invoice data and manage your financial records.
Provider Code: XERO
APIs Required for Activation #
-
Get OAuth Authorization URL
POST /api/v1/integrations/oauth/authorize- Parameters:
entity_id(required): UUID of the entityprovider:XEROredirect_url(required): URL to redirect after OAuth flow
- Response: Returns authorization URL for Xero OAuth flow
- Parameters:
-
Fetch Available Organizations
GET /api/v1/integrations/{integrationId}/accounting-organizations- Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
- Response: List of available Xero organizations/tenants
- Parameters:
-
Setup Integration
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "XERO", "organization_id": "your-xero-org-id", "pays_tax": true } }
-
Additional Xero APIs #
- Chart of Accounts:
GET /api/v1/integrations/{integrationId}/accounting-accounts - Contacts/Vendors:
GET /api/v1/integrations/{integrationId}/accounting-contacts - Tax Rates:
GET /api/v1/integrations/{integrationId}/accounting-tax-rates - Currencies:
GET /api/v1/integrations/{integrationId}/accounting-currencies
Xero Account Type Mapping #
The following table shows how Xero account types are mapped to Tofu’s internal AccountClass:
| Xero Account Type | Description | Tofu AccountClass |
|---|---|---|
| BANK | Bank account | Bank |
| CURRENT | Current Asset account | Asset |
| CURRLIAB | Current Liability account | Liability |
| DEPRECIATN | Depreciation account | Expense |
| DIRECTCOSTS | Direct Costs account | Expense |
| EQUITY | Equity account | Equity |
| EXPENSE | Expense account | Expense |
| FIXED | Fixed Asset account | Asset |
| INVENTORY | Inventory Asset account | Asset |
| LIABILITY | Liability account | Liability |
| NONCURRENT | Non-current Asset account | Asset |
| OTHERINCOME | Other Income account | Revenue |
| OVERHEADS | Overhead account | Expense |
| PREPAYMENT | Prepayment account | Asset |
| REVENUE | Revenue account | Revenue |
| SALES | Sale account | Revenue |
| TERMLIAB | Non-current Liability account | Liability |
Note:
- Tofu’s AccountClass also includes
CreditCard, but Xero doesn’t have a specific credit card account type - Unknown account types default to
Expensewith a warning logged
Account Type Filtering #
The chart of accounts API supports filtering by account type:
GET /api/v1/integrations/{integrationId}/accounting-accounts?account_types=BANK,EXPENSE
- Parameters:
account_types: Comma-separated list of AccountClass values to filter- Valid values:
ASSET,LIABILITY,EQUITY,REVENUE,EXPENSE,BANK,CREDIT_CARD - If not provided, returns all account types
QuickBooks Online (Accounting Integration) #
QuickBooks Online is a cloud-based accounting software that allows you to sync invoice data and manage your financial records.
Provider Code: QBO
QuickBooks Account Type Mapping #
QuickBooks Online uses the AccountTypeEnum to categorize accounts:
| QBO AccountType | Description | Tofu AccountClass |
|---|---|---|
| Bank | Bank accounts | Bank |
| AccountsReceivable | Accounts receivable | - |
| OtherCurrentAsset | Other current assets | Asset |
| FixedAsset | Fixed assets | Asset |
| OtherAsset | Other assets | Asset |
| AccountsPayable | Accounts payable | - |
| CreditCard | Credit card accounts | CreditCard |
| OtherCurrentLiability | Other current liabilities | Liability |
| LongTermLiability | Long-term liabilities | Liability |
| Equity | Equity accounts | Equity |
| Income | Income/Revenue accounts | Revenue |
| OtherIncome | Other income accounts | Revenue |
| CostofGoodsSold | Cost of goods sold | Expense |
| Expense | Expense accounts | Expense |
| OtherExpense | Other expense accounts | Expense |
| NonPosting | Non-posting accounts (not supported) | - |
Note:
- QBO doesn’t support the “Non-Posting” Account type for API operations
- The API returns the original AccountType value in the
classfield - Filtering uses SQL-like syntax:
AccountType = 'Bank'
Bukku (Accounting Integration) #
Bukku is a Malaysian cloud accounting platform. Unlike OAuth-based accounting connectors, Bukku uses an API Access Token and company subdomain.
Provider Code: BUKKU
Authentication: API key (no OAuth flow)
APIs Required for Activation #
-
Create API-Key Integration
POST /api/v1/integrations/api-key-
Parameters:
entity_id(required): UUID of the entity
-
Request Body:
{ "provider": "BUKKU", "credentials": { "access_token": "your-bukku-access-token", "subdomain": "your-company-subdomain" } } -
Response: Returns the created
EntityIntegrationinPENDINGstatus after credential validation -
Credential source: Bukku Control Panel → Integrations → API Access
-
Subdomain: URL prefix from
https://{subdomain}.bukku.my
-
-
Setup Integration (Activate)
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "BUKKU", "subdomain": "your-company-subdomain" } }
-
Bukku Notes #
- Read-only integration:
can_publishandcan_importare false; reference-data sync is implemented incrementally. - Feature flag: Requires
bukku_integrationenabled on the organization before the provider appears in the UI. - One accounting integration per entity: Creating Bukku fails if another accounting integration is already connected.
- Credential validation: Backend calls
GET /contacts?page=1&page_size=1withAuthorization: Bearer <access_token>andCompany-Subdomain: <subdomain>.
For end-user setup steps, see the Bukku Integration guide.
FreeAgent (Accounting Integration) #
FreeAgent is a UK-first cloud accounting product (NatWest-owned) for freelancers, contractors, and micro-businesses. API v2.
- Provider Code:
FREE_AGENT - Authentication: OAuth2 (authorization-code); no granular scopes (access follows the connecting user’s role).
Auth / Connection Model #
- Authorize:
…/v2/approve_app(the authorize URL carries onlyresponse_type,client_id,redirect_uri,state— no scope string). - Token exchange:
POST …/v2/token_endpointusing HTTP Basic auth (client_id = username, client_secret = password); form bodygrant_type=…&code=…&redirect_uri=…. - Rotating refresh tokens: the refresh grant returns a new access token AND a new refresh token — always persist the latest. There is no documented revoke endpoint; disconnect just drops the stored token.
- API version header: every request pins
X-Api-Version: 2024-10-01; unversioned requests silently follow “today’s” behavior. - User-Agent: FreeAgent rejects any request without a
User-Agentheader ("User agent http header not set"), so the HTTP client pins one. - Sandbox vs production host: selected by
ENVIRONMENT_NAME(mirrors QBO) —production→https://api.freeagent.com, everything else (dev/preview/dev_local) →https://api.sandbox.freeagent.com. Overridable per-URL viaFREEAGENT_API_BASE_URL/FREEAGENT_AUTH_URL/FREEAGENT_TOKEN_URLor the task-local runtime config (.config.yaml). - Credentials:
FREEAGENT_CLIENT_ID/FREEAGENT_CLIENT_SECRET, redirect viaOAUTH_REDIRECT_URL. One dev-dashboard app works for both sandbox and prod (the account you authorize picks the environment). - Multi-org: one authorization = one company (repeat authorization for more, like Xero/QBO).
APIs Required for Activation #
-
Get OAuth Authorization URL —
POST /api/v1/integrations/oauth/authorize(entity_id,provider: FREE_AGENT,redirect_url). -
Setup / Activate —
PUT /api/v1/integrations/{integrationId}. FreeAgent scopes one token to one company, so the activate probe isGET /v2/company(no org-picker step); the connected company URL is the tenant id. Example body:{ "status": "PENDING", "public_metadata": { "provider": "FREE_AGENT" } }
Per-Object Read Support #
| Object | Endpoint | Support | Notes |
|---|---|---|---|
| Accounts | GET /v2/categories |
✅ read | Grouped response (income / cost-of-sales / admin / general). (AccountClass, AccountCategory) derived from the group for P&L rows and from tax_reporting_name for general_categories (e.g. “Bank account”, “Debtors”, “Creditors”, “Stock”, “Wages”). Nominal code is not a reliable type signal. Real per-account bank/credit-card records live in /v2/bank_accounts (not yet fetched — follow-up). |
| Items | GET /v2/stock_items + GET /v2/price_list_items |
✅ read | Stock items → Inventory; price-list items → Service/NonInventory (sale price + income category). |
| Tax rates | GET /v2/sales_tax_periods |
✅ read | Rates come from the most recent period (sales_tax_rate_1/2/3 + universal second_sales_tax_rate_1/2/3). categories.auto_sales_tax_rate is a band name (e.g. “Standard rate”, “Zero rate”, “Outside of the scope of VAT”), not a %, so it is NOT used for rate synthesis; it is only fed verbatim into each account’s tax_type in the accounts mapping. A non-VAT-registered org has no rates (empty). |
| Tags | GET /v2/projects?view=all |
✅ read | projects are the only tracking dimension; flat, no scope/level; AI auto-assign ON. |
| Locations | — | ❌ none | FreeAgent has no branch/location concept; fetch_location() returns empty. |
| Contacts | GET /v2/contacts (view=active + clients + suppliers) |
✅ read + write-back | Single endpoint (not split). Role is activity-derived — a new contact appears only in view=active, not clients/suppliers, so active is the master list and roles are tagged from the clients/suppliers url sets (unclassified → both). Write-back: sales_tax_registration_number. |
| Currencies | GET /v2/company (base) + static supported list |
✅ read | No per-org active-currency endpoint; base currency from company.currency, plus the documented supported set (multi-currency enabled). |
| AR invoices | GET /v2/invoices (+ invoice_items) |
✅ read | Line price is tax-EXCLUSIVE. No incoming attachment — the “document” is the rendered GET /v2/invoices/:id/pdf (base64). |
| AP bills | GET /v2/bills (+ bill_items) |
✅ read | total_value is tax-INCLUSIVE; real embedded attachment (content_src). |
| Direct expense | GET /v2/expenses |
✅ read | Spend only (no receive direction); attributed to a user, not a contact (no by-contact lookup). |
Calculation Config #
TaxCalculationMode = LineByLine, RoundingMethod = Nearest (UK VAT round-half-away), per-doc-type basis (AR exclusive, AP/expense inclusive), currency precision from the document currency (GBP 2dp). Registered as AccountingTarget::FreeAgent (YAML freeagent + codegen) and served by EntityIntegrationAccountingConfigService.
FreeAgent Notes #
- Read-only + contact write-back:
read_only_access().with_create_contacts().with_direct_expense_self_prompt()—can_publish/can_importare false; contact create/update (tax-id) is the only write. - Feature flag: requires
freeagent_integrationenabled on the organization. - Resource ids are bare, not URLs. FreeAgent identifies resources by full URI, but the id must survive single-segment internal paths (
/accounting-invoices/{id},/contact-knowledge/{id}), so invoice/bill/expense/contact external ids are stored as the bare last URL segment (e.g.743559) and the fetch/write layer rebuilds the full API URL. - Decode-hardening: all decoders are open-world (
#[serde(default)], tolerate unknown fields, no panic on unknown enum). Notecontact_name_on_invoicesis a boolean toggle (not a name). - Known gaps: attachment download-by-id path is not yet path-safe (the attachment id still embeds a URL); JPY zero-decimal + calculator reconciliation fixtures pending a sandbox probe; no invoice-history endpoint; production app / high-volume sign-off is a dev-portal chore.
Microsoft Dynamics 365 Business Central (Accounting Integration) #
Microsoft Dynamics 365 Business Central (BC) is Microsoft’s cloud ERP for small and mid-sized businesses. Only the online SaaS product is supported, via BC REST API v2.0; on-premises Business Central is out of scope.
⚠️ Dynamics 365 is a suite, not one product. This connector targets Business Central only. Dynamics 365 Finance & Operations (F&O) is a different product with a different API and is NOT supported — check which product the customer actually runs before triaging a “Dynamics” ticket.
- Provider Code:
DYNAMICS_BC - Authentication: OAuth2 (authorization-code) via Microsoft Entra ID, delegated — the connecting user must hold a Business Central license.
Auth / Connection Model #
- Authorize/token endpoints:
https://login.microsoftonline.com/common/oauth2/v2.0/authorize/.../token(the multi-tenantcommonendpoint, so any BC tenant can consent). - Scope:
https://api.businesscentral.dynamics.com/.default offline_access—offline_accessis mandatory; without it Entra issues no refresh token. - Token lifecycle (support-relevant): access token ~1 h; the refresh token is 90-day rolling and rotates on every use. It is revoked when the connecting user is disabled, resets their password, or an admin revokes consent — the heartbeat then flips the integration to unauthorized and the customer must reconnect. This is the #1 expected support scenario for this connector.
- Composite “organization”: unlike Xero’s single org-picker, BC addresses data by Entra tenant × environment × company, so activation has two picker levels (environment, then company). The selector is stored as
{tenantId}:{environmentName}:{companyId}. - Company-scoped URLs: every data call hits
{host}/v2.0/{tenant}/{environment}/api/v2.0/companies({companyId})/<entity>. Environment discovery isGET {host}/environments/v1.2(bare host, NOT under the API base). - Read-replica reads: all sync GETs (including attachment downloads) send
Data-Access-Intent: ReadOnly. - Rate limits: 6,000 requests / 5 min, 5 concurrent. 429 responses carry no
Retry-Afterheader, so the client applies a conservative 30 s fallback backoff viaIntegrationRetryAfterCache. - Credentials:
DYNAMICS_BC_CLIENT_ID/DYNAMICS_BC_CLIENT_SECRET, redirect viaOAUTH_REDIRECT_URL. Host/auth/token URLs are overridable viaDYNAMICS_BC_API_BASE_URL/DYNAMICS_BC_AUTH_URL/DYNAMICS_BC_TOKEN_URLor the task-local runtime config (.config.yaml).
APIs Required for Activation #
-
Get OAuth Authorization URL
POST /api/v1/integrations/oauth/authorize- Parameters:
entity_id(required): UUID of the entityprovider:DYNAMICS_BCredirect_url(required): URL to redirect after OAuth flow
- Response: Returns the Microsoft Entra ID authorization URL
- Parameters:
-
Fetch Available Organizations
GET /api/v1/integrations/{integrationId}/accounting-organizations- Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
- Response: List of environment × company pairs. The backend enumerates environments (
GET /environments/v1.2), then companies per environment; each organization id is the composite selector{tenantId}:{environmentName}:{companyId}.
- Parameters:
-
Setup Integration
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "DYNAMICS_BC", "tenant_id": "f1e2d3c4-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "environment_name": "production", "company_id": "6b1c31d8-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } } -
The activate request carries only the selector;
company_name,base_currency, andmulti_currency_enabledare resolved from Business Central server-side. -
Activation also seeds the company’s tax codes as internal, editable Tofu tax rates (see Tax rates below).
-
Per-Object Support #
| Object | BC entity (API v2.0) | Support | Notes |
|---|---|---|---|
| Accounts (CoA) | accounts |
✅ read | GET-only. Only non-blocked accountType == "Posting" rows are ingested (Heading/Total dropped). The CoA yields no Bank/CreditCard classes. |
| Items | items |
✅ read | type Inventory / Service / Non-Inventory; priceIncludesTax is per item. |
| Tax rates | taxGroups |
⚠️ partial | BC exposes no tax percentage anywhere in v2.0 (only a read-only, server-computed taxPercent on document lines). Tax codes are ingested as 0% shells and seeded at activation as internal, editable Tofu tax rates (AutoCount pattern — usesInternalTaxRates); the customer fills in the percentages in Tofu. On VAT tenants the same entity returns VAT Product Posting Groups. |
| Tags | dimensions + dimensionValues |
✅ read | GET-only; dimension → non-selectable parent, value → selectable child; tags apply at header AND line level. BC exposes no per-dimension scope/level. |
| Locations | — | ❌ not synced | fetch_location is unimplemented; line locationId is not resolved against a location master. |
| Contacts | customers + vendors |
✅ read + write-back | Split collections (QBO pattern) merged with fixed roles (vendor → supplier, customer → customer); fully-blocked parties dropped; a dual-role company is two BC records (no dedupe). The CRM contacts entity is NOT mapped. Write-back: create supplier/customer + update taxRegistrationNumber (TIN). |
| Currencies | currencies (+ currencyExchangeRates read-only) |
✅ read | The local currency (LCY) is NOT a row in currencies — it comes from generalLedgerSetup.localCurrencyCode. amountDecimalPlaces is a string (e.g. "2:2"). |
| AR invoices | salesInvoices (+ salesInvoiceLines via $expand) |
✅ read | Aggregate entity — listing filters status ne 'Draft'; server-computed net* amounts are trusted. |
| AP bills | purchaseInvoices (+ purchaseInvoiceLines via $expand) |
✅ read | Same aggregate/status caveats; by-contact filtering keys on vendorId (AP) / customerId (AR). |
| Direct expenses | purchaseInvoices with lineType == "Account" lines |
⚠️ partial | BC has no first-class expense entity — non-draft purchase invoices are classified client-side into the direct-expense feed (and excluded from the AP-bill feed). |
| Bank statements | — | ❌ unsupported | bankAccountLedgerEntries does not exist in API v2.0; bankAccounts is a master list only. |
Attachments: an invoice/bill’s documents are read from two collections (attachments and documentAttachments) plus the BC-rendered pdfDocument. The stored attachment id is prefixed (att: / docatt: / pdf:) and download routes by prefix to attachments({id})/attachmentContent, documentAttachments({id})/attachmentContent, or pdfDocument/pdfDocumentContent. Attachment upload/delete is not supported (read-only connector).
Business Central Account Category Mapping #
BC’s category field (“G/L Account Category”) maps onto Tofu’s AccountClass:
| BC Account Category | Tofu AccountClass |
|---|---|
| Assets | Asset |
| Liabilities | Liability |
| Equity | Equity |
| Income | Revenue |
| Cost of Goods Sold | Expense |
| Expense | Expense |
Note:
- Blank/unknown categories default to
Expensewith a warning logged. - Only
accountType == "Posting"rows are ingested;Heading/Total/Begin Total/End Totalrows are dropped. - The BC chart of accounts never yields
Bank/CreditCardclasses — bank accounts live in the separatebankAccountsentity, which is not ingested.
Business Central Notes #
- Read-only + contact write-back:
read_only_access().with_create_contacts().with_direct_expense_self_prompt().with_have_header_level_tags()—can_publish/can_sync/can_importare false. Self-prompt is ON for AP bills, AR invoices, AND direct expenses; header-level tags are ON. Contact create/update (TIN) is the only write to BC. - Feature flag: requires
dynamics_bc_integrationenabled on the organization before the provider appears in the UI. - Known gaps / runtime notes:
- Sales/purchase invoices are aggregate entities with a shared status enum (
" ",Draft,In Review,Open,Paid,Canceled,Corrective); a posted document’siddiffers from itssystemId. - VAT is computed per document per VAT-Identifier with a manual tolerance, so server
net*totals are trusted rather than recomputed; opt-in invoice rounding can add an extra G/L line;pricesIncludeTaxis per-document. - Enum/format quirks:
blockedis a string enum (with a blank member) on customers/vendors but a boolean on items/accounts;amountDecimalPlacesis a string;Commentlines carry no amounts. - All decoders are open-world (live payloads exceed the documented schema; vNext adds enum members such as
Allocation Account). - Localization drift: US (Sales Tax) vs EU/UK (VAT) tenants populate fields differently;
taxTypefollows the tenant’sGeneralLedgerSetup.UseVat(). - Pagination follows the server’s absolute
@odata.nextLink, validated against the API host before following (SSRF guard), with a defensive 50-page cap per call. - Tag auto-assign: BC dimensions expose no scope/level, so per the ENG-5093 pattern AI tag auto-assign should be OFF, but the capabilities arm currently leaves it at the read-only default (ON) — pending reconciliation.
- Sandbox-dependent, still unconfirmed:
Retry-Afterpresence on 429;lastModifiedDateTime$filterbehavior; whether LCY ever appears incurrencies; G/L Setup rounding defaults / VAT Rounding Type.
- Sales/purchase invoices are aggregate entities with a shared status enum (
SharePoint (Trigger Integration) #
SharePoint integration allows you to monitor specific SharePoint sites and document libraries for new documents.
Provider Code: SHAREPOINT
APIs Required for Activation #
-
Get OAuth Authorization URL
POST /api/v1/integrations/oauth/authorize- Parameters:
entity_id(required): UUID of the entityprovider:SHAREPOINTredirect_url(required): URL to redirect after OAuth flow
- Response: Returns authorization URL for SharePoint OAuth flow
- Parameters:
-
Fetch Available Sources
GET /api/v1/integrations/{integrationId}/trigger-sources- Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
- Response: List of available SharePoint sites
- Parameters:
-
Fetch Sub-sources (Document Libraries)
GET /api/v1/integrations/{integrationId}/trigger-sources/{sourceId}/sub-sources- Parameters:
integrationId(required): UUID of the integrationsourceId(required): SharePoint site IDentity_id(required): UUID of the entity
- Response: List of document libraries in the specified site
- Parameters:
-
Setup Integration
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "SHAREPOINT", "source_id": "sharepoint-site-id", "sub_source_id": "document-library-id", "directories": ["path/to/watch", "another/path"] } }
-
Google Drive (Trigger Integration) #
Google Drive integration allows you to monitor specific Google Drive folders for new documents.
Provider Code: GOOGLE_DRIVE
APIs Required for Activation #
-
Get OAuth Authorization URL
POST /api/v1/integrations/oauth/authorize- Parameters:
entity_id(required): UUID of the entityprovider:GOOGLE_DRIVEredirect_url(required): URL to redirect after OAuth flow
- Response: Returns authorization URL for Google Drive OAuth flow
- Parameters:
-
Fetch Available Sources
GET /api/v1/integrations/{integrationId}/trigger-sources- Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
- Response: List of available Google Drive folders
- Parameters:
-
Fetch Sub-sources (Sub-folders)
GET /api/v1/integrations/{integrationId}/trigger-sources/{sourceId}/sub-sources- Parameters:
integrationId(required): UUID of the integrationsourceId(required): Google Drive folder IDentity_id(required): UUID of the entity
- Response: List of sub-folders in the specified folder
- Parameters:
-
Setup Integration
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "GOOGLE_DRIVE", "source_id": "google-drive-folder-id", "sub_source_id": "sub-folder-id", "directories": ["path/to/watch", "another/path"] } }
-
Complete API Documentation #
For detailed API specifications, request/response schemas, and additional endpoints, refer to the OpenAPI documentation.