Integration APIs

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:

  1. Accounting Integrations - Connect to accounting software for invoice synchronization
  2. Trigger Integrations - Connect to document storage systems for automated document processing

At a minimum integrations of any type require two steps to fully integrate.

  1. OAuth Integration Flow
  2. 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 #

  1. Get OAuth Authorization URL

    POST /api/v1/integrations/oauth/authorize
    
    • Parameters:
      • entity_id (required): UUID of the entity
      • provider: XERO
      • redirect_url (required): URL to redirect after OAuth flow
    • Response: Returns authorization URL for Xero OAuth flow
  2. Fetch Available Organizations

    GET /api/v1/integrations/{integrationId}/accounting-organizations
    
    • Parameters:
      • integrationId (required): UUID of the integration
      • entity_id (required): UUID of the entity
    • Response: List of available Xero organizations/tenants
  3. Setup Integration

    PUT /api/v1/integrations/{integrationId}
    
    • Parameters:

      • integrationId (required): UUID of the integration
      • entity_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 Expense with 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 class field
  • 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 #

  1. 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 EntityIntegration in PENDING status after credential validation

    • Credential source: Bukku Control Panel → Integrations → API Access

    • Subdomain: URL prefix from https://{subdomain}.bukku.my

  2. Setup Integration (Activate)

    PUT /api/v1/integrations/{integrationId}
    
    • Parameters:

      • integrationId (required): UUID of the integration
      • entity_id (required): UUID of the entity
    • Request Body:

      {
        "status": "PENDING",
        "public_metadata": {
          "provider": "BUKKU",
          "subdomain": "your-company-subdomain"
        }
      }
      

Bukku Notes #

  • Read-only integration: can_publish and can_import are false; reference-data sync is implemented incrementally.
  • Feature flag: Requires bukku_integration enabled 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=1 with Authorization: Bearer <access_token> and Company-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 only response_type, client_id, redirect_uri, state — no scope string).
  • Token exchange: POST …/v2/token_endpoint using HTTP Basic auth (client_id = username, client_secret = password); form body grant_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-Agent header ("User agent http header not set"), so the HTTP client pins one.
  • Sandbox vs production host: selected by ENVIRONMENT_NAME (mirrors QBO) — productionhttps://api.freeagent.com, everything else (dev/preview/dev_local) → https://api.sandbox.freeagent.com. Overridable per-URL via FREEAGENT_API_BASE_URL / FREEAGENT_AUTH_URL / FREEAGENT_TOKEN_URL or the task-local runtime config (.config.yaml).
  • Credentials: FREEAGENT_CLIENT_ID / FREEAGENT_CLIENT_SECRET, redirect via OAUTH_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 #

  1. Get OAuth Authorization URLPOST /api/v1/integrations/oauth/authorize (entity_id, provider: FREE_AGENT, redirect_url).

  2. Setup / ActivatePUT /api/v1/integrations/{integrationId}. FreeAgent scopes one token to one company, so the activate probe is GET /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_import are false; contact create/update (tax-id) is the only write.
  • Feature flag: requires freeagent_integration enabled 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). Note contact_name_on_invoices is 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-tenant common endpoint, so any BC tenant can consent).
  • Scope: https://api.businesscentral.dynamics.com/.default offline_accessoffline_access is 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 is GET {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-After header, so the client applies a conservative 30 s fallback backoff via IntegrationRetryAfterCache.
  • Credentials: DYNAMICS_BC_CLIENT_ID / DYNAMICS_BC_CLIENT_SECRET, redirect via OAUTH_REDIRECT_URL. Host/auth/token URLs are overridable via DYNAMICS_BC_API_BASE_URL / DYNAMICS_BC_AUTH_URL / DYNAMICS_BC_TOKEN_URL or the task-local runtime config (.config.yaml).

APIs Required for Activation #

  1. Get OAuth Authorization URL

    POST /api/v1/integrations/oauth/authorize
    
    • Parameters:
      • entity_id (required): UUID of the entity
      • provider: DYNAMICS_BC
      • redirect_url (required): URL to redirect after OAuth flow
    • Response: Returns the Microsoft Entra ID authorization URL
  2. Fetch Available Organizations

    GET /api/v1/integrations/{integrationId}/accounting-organizations
    
    • Parameters:
      • integrationId (required): UUID of the integration
      • entity_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}.
  3. Setup Integration

    PUT /api/v1/integrations/{integrationId}
    
    • Parameters:

      • integrationId (required): UUID of the integration
      • entity_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, and multi_currency_enabled are 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 Expense with a warning logged.
  • Only accountType == "Posting" rows are ingested; Heading/Total/Begin Total/End Total rows are dropped.
  • The BC chart of accounts never yields Bank/CreditCard classes — bank accounts live in the separate bankAccounts entity, 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_import are 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_integration enabled 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’s id differs from its systemId.
    • 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; pricesIncludeTax is per-document.
    • Enum/format quirks: blocked is a string enum (with a blank member) on customers/vendors but a boolean on items/accounts; amountDecimalPlaces is a string; Comment lines 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; taxType follows the tenant’s GeneralLedgerSetup.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-After presence on 429; lastModifiedDateTime $filter behavior; whether LCY ever appears in currencies; G/L Setup rounding defaults / VAT Rounding Type.

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 #

  1. Get OAuth Authorization URL

    POST /api/v1/integrations/oauth/authorize
    
    • Parameters:
      • entity_id (required): UUID of the entity
      • provider: SHAREPOINT
      • redirect_url (required): URL to redirect after OAuth flow
    • Response: Returns authorization URL for SharePoint OAuth flow
  2. Fetch Available Sources

    GET /api/v1/integrations/{integrationId}/trigger-sources
    
    • Parameters:
      • integrationId (required): UUID of the integration
      • entity_id (required): UUID of the entity
    • Response: List of available SharePoint sites
  3. Fetch Sub-sources (Document Libraries)

    GET /api/v1/integrations/{integrationId}/trigger-sources/{sourceId}/sub-sources
    
    • Parameters:
      • integrationId (required): UUID of the integration
      • sourceId (required): SharePoint site ID
      • entity_id (required): UUID of the entity
    • Response: List of document libraries in the specified site
  4. Setup Integration

    PUT /api/v1/integrations/{integrationId}
    
    • Parameters:

      • integrationId (required): UUID of the integration
      • entity_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 #

  1. Get OAuth Authorization URL

    POST /api/v1/integrations/oauth/authorize
    
    • Parameters:
      • entity_id (required): UUID of the entity
      • provider: GOOGLE_DRIVE
      • redirect_url (required): URL to redirect after OAuth flow
    • Response: Returns authorization URL for Google Drive OAuth flow
  2. Fetch Available Sources

    GET /api/v1/integrations/{integrationId}/trigger-sources
    
    • Parameters:
      • integrationId (required): UUID of the integration
      • entity_id (required): UUID of the entity
    • Response: List of available Google Drive folders
  3. Fetch Sub-sources (Sub-folders)

    GET /api/v1/integrations/{integrationId}/trigger-sources/{sourceId}/sub-sources
    
    • Parameters:
      • integrationId (required): UUID of the integration
      • sourceId (required): Google Drive folder ID
      • entity_id (required): UUID of the entity
    • Response: List of sub-folders in the specified folder
  4. Setup Integration

    PUT /api/v1/integrations/{integrationId}
    
    • Parameters:

      • integrationId (required): UUID of the integration
      • entity_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.