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

Zoho Books (Accounting Integration) #

Zoho Books supports importing and publishing accounting documents through its REST API v3.

Provider Code: ZOHO

Publish support #

Document Support Notes
AP bill Create, delete Bill number, supplier, date, and an account on every line are required. Republishing deletes the previous bill and creates a new one — there is no in-place update.
AR invoice Create, delete Zoho auto-numbers the invoice; each line requires a Zoho item. Republishing deletes the previous invoice and creates a new one — there is no in-place update.
Direct expense Spend only Supports flat and itemized expenses plus a single receipt. Receive-money remains read-only.
Bank statement Statement import Feed lines land uncategorized in Zoho banking for reconciliation there. Contra accounts, tax, and tags from the BonsAI review are not transmitted.
Attachments PDF and images Bills, invoices, and expense receipts accept PDF, PNG, JPEG, GIF, and BMP.

Publishing uses the Zoho organization selected during setup and sends organization_id on every request. Bank-statement import requires the ZohoBooks.banking.CREATE OAuth scope. Existing connections must be reconnected after this feature is deployed so the user can grant the new invoice, bill, expense, accountant, and banking permissions. A connection with the old grant cannot publish bank statements and Zoho returns an authorization error on the write call.

UAE and Dubai organizations #

For UAE organizations, BonsAI carries the selected contact’s tax_treatment and place_of_contact into publish requests as Zoho’s tax_treatment and place_of_supply. Dubai’s Zoho place-of-supply code is DU. Header-level reporting tags and the selected Zoho location_id are also included, allowing Dubai entities with multiple branches to preserve their reporting dimensions.

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.
  • 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: requests pin X-Api-Version: 2024-10-01; unversioned requests silently follow “today’s” behavior. Bank transaction explanation and bank statement calls pin 2026-09-01 per request, because explanations switch to a multi-attachment shape on 2026-12-01.
  • 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) — production → https://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).
  • Access levels (the connecting user’s role): Bills 5, Invoices 4, Banking 6, My Money 2. Publishing every type needs Banking, so connect as a Banking or Full-access user. Reads work at lower levels.

APIs Required for Activation #

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

  2. Setup / Activate — PUT /api/v1/integrations/{integrationId}. FreeAgent scopes one token to one company, so the activate probe is GET /v2/company (no org-picker step); the tenant key is the numeric company_id from that response (company_url is …/v2/company for every company, so it can’t tell two companies apart). Public metadata also stores subdomain, company_type, sales_tax_effective_date, ec_vat_reporting_enabled and user_url (the connecting user, from GET /v2/users/me; direct expenses are recorded against them). Rows activated before company_id existed still match on the URL and are backfilled on the next read or heartbeat. Until then, activation treats such a row in the same tofu organization as holding the company when its stored company name matches the live one (trimmed, case-insensitive), so the duplicate-company check still applies to it. 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/company ✅ read Rates come from the company’s sales_tax_rates (+ universal second_sales_tax_rates). GET /v2/sales_tax_periods is not used: it returns 404 for UK companies, and a Universal company’s newest period can be future-dated. 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 Negative gross_value = Spend, positive = Receive (a refund); attributed to a user, not a contact (no by-contact lookup). Publish writes the same object, see below.

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 #

  • Capabilities: read_only_access().with_create_contacts().with_direct_expense_self_prompt().with_publish().with_credit_note_publish().without_ap_credit_note_publish().without_ar_invoice_attachments() plus contact create/update (tax-id). Import stays off. Bank statement publish is enabled through supports_publish_for_type (Rust) and supportsPublishForType (webapp).
  • Auto-publish starts off on first connect, so nothing is written to a customer’s books without opting in. Existing read-only connections keep their entity flags; check them before release. No reconnect is needed: FreeAgent has no scopes.
  • 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.

Publish #

Type FreeAgent object Create Republish Delete Paid documents Attachments
AP bill /v2/bills (posted as Open, there is no draft) ✅ PUT in place (items by url, removed items _destroy) DELETE; 404 counts as success Refused up front when paid_value > 0 (BILL_HAS_PAYMENTS) One file inline, max 5 MB; re-upload replaces
AR invoice /v2/invoices (created Draft, then mark_as_sent unless the status is Draft; the status follows the entity’s status-on-publish setting, default Submitted, so the invoice is marked as sent) ✅ mark_as_draft → PUT (items by integer id) → mark_as_sent DELETE (Draft only); 409 on sent/paid is mapped mark_as_draft 403 → INVOICE_HAS_PAYMENTS not possible; accounting-sync skips the attachment step (capability can_attach_ar_invoice_files = false)
Direct expense Expense claim /v2/expenses against the connecting user; Spend = negative gross_value (payment), Receive = positive (refund) ✅ one expense per line; on a failure the ones already created are deleted PUT each line’s expense in place, create added lines, delete removed ones; keeps the first expense’s user DELETE every expense; 404 counts as success A locked expense (locked period, filed VAT return) → DOCUMENT_LOCKED One file inline on the first expense, max 5 MB; re-upload replaces (FreeAgent deletes the old file)
Bank statement POST /v2/bank_transactions/statement, then explanations for lines with a contra account ✅ ❌ already-published guard; correct it in FreeAgent Explanations, then transactions, in reverse order on failure — —
AR credit note /v2/credit_notes with negated prices (quantity ≥ 0), terms 0, created Draft then mark_as_sent (a failed send deletes the new draft) ✅ mark_as_draft (only if sent) → PUT (every existing item _destroy: 1, new items added) → mark_as_sent; inside upload_credit_note A sent note goes back to draft, then DELETE; 404 counts as success refunded_value ≠ 0 (applied or refunded) is locked (CREDIT_NOTE_ALLOCATED); Refunded reads as Paid, non-zero → Part Paid not possible; skipped like AR invoices (can_attach_ar_invoice_files = false), an upload is AR_ATTACHMENT_NOT_SUPPORTED
AP credit note Not supported: FreeAgent has no supplier credit-note object (decision 2026-10-13); can_publish_ap_credit_note = false hides Publish, and the connector refuses one ❌ — — — —

Not supported: AP credit notes, creating bank accounts, multi-line direct expense splits, a paid-from account on direct expenses, tax on a foreign-currency direct expense (FOREIGN_CURRENCY_TAX_RATE), EC Goods / EC Services / MOSS treatments, second sales tax. Imported /v2/expenses claims share the id space with published ones, so tofu can republish or delete them too.

Error mapping (handle_response):

Response Result
401 Disconnected (reconnect)
403 on a /transitions/ URL DOCUMENT_LOCKED (e.g. “This Invoice cannot be marked as draft”)
403 elsewhere INSUFFICIENT_ACCESS_LEVEL, not a disconnect: reads still work and reconnecting as the same user won’t help. Fix: connect as a Banking or Full-access user
409 (empty body) on DELETE DOCUMENT_LOCKED
422 containing is locked and cannot be changed DOCUMENT_LOCKED. The connector never returns InvoiceLocked, which accounting-sync silently skips on update
other 400 / 422 BadRequest with the messages joined by "; " (400 object, 422 array and {"error": "…"} shapes are all parsed)
429 / 5xx TooManyRequests (honours Retry-After) / ExternalServerError

Quirks:

  • Idempotency: none on create. Bill and expense references are not unique, so a retry after a lost response can duplicate an AP bill or direct expense. Invoice reference is unique, so an AR retry fails with REFERENCE_ALREADY_USED instead.
  • Numbering: AP sends the tofu invoice number as reference (required). AR omits it unless set, and FreeAgent auto-sequences.
  • Tax rate: the write side takes a percentage, parsed from the synthetic tax rate id freeagent-tax-rate-<pct>. A rate is always sent explicitly, because an omitted rate means Auto on UK companies (the category’s rate, often 20 %). AR sends net price, so tax-inclusive documents are converted before sending.
  • Tags (projects): line level on AR, header level on AP (a bill-line project is dropped by FreeAgent), per line on direct expenses (each line’s expense; the header project fills lines without one). FreeAgent silently ignores an unknown project URI, so tofu validates against the tag catalog.
  • Currency: a foreign-currency bill or expense can’t carry a tax rate (FreeAgent sets it to 0 on expenses and wants manual_sales_tax_amount in the base currency, which tofu has no exchange rate for), so the connector refuses it with FOREIGN_CURRENCY_TAX_RATE. A statement must be in its bank account’s currency.
  • Validator: FreeAgent’s rules run in tofu-validator-core (live in the review form), so most of the errors above surface before Publish.
  • Credit notes: AR only. The note does not link to the original invoice; its number goes into comments. No tax treatment (reverse charge, exempt, out of scope) on credit notes, and a header discount is dropped with a warning. An AR credit note shares the invoice number sequence, so a blank number takes the next invoice number. The validator blocks Stock category lines on AR credit notes (FreeAgent would move them to Sales) and a foreign-currency AR credit note dated after today on UK companies (FreeAgent won’t send it); “today” is passed in by the caller. If FreeAgent still refuses a send with its 403 “future-dated foreign currency with sales tax”, its own message is shown (AR invoices too). Error keys: CREDIT_NOTE_ALLOCATED, CREDIT_NOTE_NOT_NEGATIVE, CREDIT_NOTE_NUMBER_ALREADY_USED. Details: integrations/credit-note-publish.md.
Document tax treatment #

Like Zoho, a document carries a tax_treatment (AP, AR and direct expense). Stored values and availability:

Value FreeAgent effect Company type Document types
uk_non_ec Header ec_status: "UK/Non-EC" (FreeAgent’s default; used when nothing is picked) UK only AP, AR, DE
reverse_charge Header ec_status: "Reverse Charge"; needs ec_vat_reporting_enabled and a date on or after sales_tax_effective_date; an AR document with a non-zero line rate is blocked UK only AP, AR, DE
exempt Every line sales_tax_status: EXEMPT, no rate UK only AP, AR, DE
out_of_scope Every line sales_tax_status: OUT_OF_SCOPE, no rate UK and Universal AP, DE (FreeAgent rejects it on invoices)

“UK” means a company type starting with Uk (e.g. UkLimitedCompany); anything else is treated as Universal. A NoTax document with no treatment goes out zero-rated with sales_tax_rate: "0" (AR) or OUT_OF_SCOPE (AP/DE). An unavailable value fails with TAX_TREATMENT_NOT_VALID.

Direct expense = expense claim #

A direct expense is published as FreeAgent expenses (POST /v2/expenses), one per line: out-of-pocket claims by the user who connected tofu (user_url in the public metadata; rows connected before it was stored fetch GET /v2/users/me at publish until the next heartbeat fills it). A republish keeps the first expense’s user, so an imported claim stays with its claimant. If that user has left the company FreeAgent answers “User not found” → EXPENSE_USER_NOT_FOUND (reconnect).

  • Direction: Spend goes out as a negative gross_value (FreeAgent’s payment), Receive as a positive one (a refund). Both take only Admin expenses or Cost of sales categories (income and general categories are refused in both directions, catalog E22).
  • No paid-from account: an expense has no bank account. FreeAgent bank accounts only carry the bank-statement slot, so the review form hides the paid-from picker for FreeAgent and the validator doesn’t ask for it.
  • One expense per line: each line’s expense takes the line’s category, tax rate, amount and project (the header project when the line has none), and the line’s description (else the header’s, else the contact name). Description lines are skipped. A zero line is refused (TOTAL_MUST_BE_NON_ZERO_FREEAGENT, FreeAgent rejects a zero expense) and so is a line with two projects (ONE_PROJECT_PER_EXPENSE_FREEAGENT). A Spend line with a negative amount goes out as a refund. Expenses are created in line order; if one fails, those already created are deleted (newest first) and the error is returned.
  • Republish: the first line always keeps the first expense; the other lines keep the expense their line id names, else take the next unused one in order (tofu drops line ids when a document is edited). Added lines are created, expenses of removed lines are deleted after every update. An expense deleted in FreeAgent is created again, except the first, which ends the republish as not found. A failure deletes what this republish created; updates already made stay and the next publish rewrites them.
  • Attachment: the file goes on the first expense only.
  • Tax: the document tax treatment maps as on bills (ec_status on UK companies, sales_tax_status for Exempt / Outside the scope, otherwise an explicit rate). Expenses take any rate on any date, so the bank-payment rules (positive rate on general categories, VAT registration window, company start date) don’t apply.
  • Ids and links: the header external id lists the bare expense ids in line order, comma-separated (580651,580652; no /, so it fits single-segment internal paths), and each line’s external id is its own expense id. A header with a single id (published before per-line expenses, or imported) reads as a one-element list. The deep link opens the first expense: https://<subdomain>[.sandbox].freeagent.com/expenses/<first id>/edit (FreeAgent has no read-only expense page). Delete removes every listed expense, the first last.
  • Read-back: every listed expense is fetched (one line each, in order); expenses already deleted are skipped and the document is not found only when all are gone. The document is a refund when the expenses add up to one, and each line keeps its sign against the document. The attachment is read from the first expense. Sign, treatment and rate map back as before. Calls use the pinned API version.
Bank statement #

Each account group is published synchronously inside the bonsapi publish request:

  1. Validate (validate_bank_statement_for_publish): the group’s account must resolve to a /v2/bank_accounts/:id URI with the bank-statement slot (a legacy 750-N id is followed to its bank account), the statement currency must match the account’s (BANK_STATEMENT_CURRENCY_NOT_ACCOUNT_CURRENCY), and the whole request is built as a dry run. If the account list can’t be fetched, publish fails closed with BANK_ACCOUNT_VALIDATION_UNAVAILABLE.
  2. Fitid (assign_transaction_ids): every top-level line gets tofu:<account group id>:<date>:<amount>:<n> in its external_id (n counts identical date/amount lines in the group), persisted before the upload. It is built from the line, not its id, because saving a statement mints new line ids: after BANK_STATEMENT_IMPORT_PENDING, an edit and a retry, unchanged lines keep their fitid and FreeAgent dedups them. A line whose only change is its description keeps its fitid too, so FreeAgent keeps the first upload’s description (its explanation gets the new one).
  3. Pre-flight: the account’s transactions over the statement’s dates are read. A fitid of this group that the statement no longer has (a line whose date or amount was edited, or that was removed, after an upload FreeAgent was still importing) → BANK_STATEMENT_LINE_CHANGED before anything is sent, so FreeAgent never holds both versions. The search covers the lines’ dates widened to the statement period; a line moved to a date outside both is not found, and neither is an earlier upload of a statement saved before it had an account group (the fitid scope changes when the group is created).
  4. Upload all top-level lines in one POST /v2/bank_transactions/statement?bank_account=<uri> (dated_on, signed amount, description, fitid, transaction_type DEBIT/CREDIT). The response is an empty 200 with no ids. FreeAgent dedups on fitid (sandbox-verified: a re-upload of the same fitid adds nothing, and a fitid whose transaction was deleted imports again).
  5. Poll GET /v2/bank_transactions?bank_account&from_date&to_date (the same range as the pre-flight) up to 5 × 2 s until every fitid is present. Still missing → BANK_STATEMENT_IMPORT_PENDING; publishing again is safe. A fitid found with another date or amount (left over from an earlier attempt) → BANK_STATEMENT_LINE_CHANGED.
  6. Explain lines that have a contra account (bank_line_contra_* helpers) with POST /v2/bank_transaction_explanations {bank_transaction, category, gross_value, sales_tax_rate, project, ec_status}. The rate is always explicit ("0" without a tax rate); UK companies send ec_status: "UK/Non-EC". Lines without a contra account, zero lines, and transactions FreeAgent already shows as explained are left alone for the user to explain in FreeAgent.
  7. Split lines: the parent’s amount is uploaded; each child with a contra account becomes an explanation on the parent’s bank transaction, and an uncategorized child leaves that part unexplained.
  8. On failure this group’s explanations, then its transactions, are deleted newest first; a delete that fails is logged as a possible orphan.

Ids: line external_id = the bank transaction id (bare), a split child’s = bte:<explanation id>, group external_id = freeagent-bs:<bank account id>:<first line date>:<last line date>. Calls pin API version 2026-09-01.

Not sent: a document tax treatment (bank lines have none, so Exempt / Outside the scope / Reverse Charge aren’t available), contacts (explanations have no contact), transfers between bank accounts (bank accounts aren’t offered as line accounts), more than one project per line. A published statement can’t be corrected or deleted from tofu.

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_access — offline_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.

SQL Account (Accounting Integration) #

SQL Account is the dominant SME accounting package in Malaysia (eStream Software), also sold in Singapore. It is a desktop Delphi application over Firebird with an HTTP API (“REST API 2.0”) served either by SQL’s hosted cloud or by an API service the customer runs on their own machine. Every request goes to the single gateway https://api.sql.my.

⚠️ SQL Account is desktop software, not SaaS. There is no customer-facing web console, so there are no per-document deep links, and an on-premise customer’s “server” is their own PC — see Deployment topology.

  • Provider Code: SQL_ACCOUNT
  • Authentication: API key — AWS Signature v4 request signing (no OAuth, no bearer token, no dev portal)

Status. Reads, contact write-back and publish are all shipped. Publish covers AP bills, AR invoices, AP and AR credit notes and direct expenses — see SQL Account Publish. Bank statement publish is deferred by decision and attachments are not supported by the provider API.

Auth / Connection Model #

  • Signing: AWS SigV4, region ap-southeast-5, service sqlaccount, SignedHeaders: host;x-amz-date and nothing else. Hand-rolled in libs/rust/bonsai-integration/src/accounting/sql_account/sigv4.rs — no AWS SDK dependency; SQL Account is not AWS, it only borrows the scheme. A golden-vector test guards it.
  • Host: always the gateway https://api.sql.my, overridable by SQL_ACCOUNT_API_BASE_URL or the task-local runtime config (.config.yaml). ⚠️ Never the per-tenant <hash>.sql.my host — it accepts the signature and then 404s with an IntraWeb HTML page.
  • 🚨 A browser User-Agent is mandatory. SQL’s edge silently 404s any request whose UA is not browser-like, returning an HTML page rather than a 403 — on writes as well as reads. reqwest’s default UA is 404’d exactly like Python’s. The connector pins a Chrome UA on the HTTP client, not per call, so the activate and heartbeat probes carry it too. The official Postman collection hides this behind a collection-level pre-request script, so testing only in Postman will never reveal it.
  • Bodied requests need an explicit Content-Type: application/json. Without it the gateway answers 401 "Invalid signature", which reads as a signing bug and is not one.
  • No token lifecycle at all: no expiry, no refresh, no rotation, and no revoke endpoint. The secret key is wrapped in an OauthToken with expires_at = i64::MAX purely so the shared credential path works; refresh_token returns the stored key unchanged and exchange_code / revoke_token return errors by construction. Disconnect just clears the stored secret. A key dies only when the customer regenerates or deletes it in their own installation.
  • No scopes. One key carries its SQL user’s module rights for read and write, so read-only ships first with no reconnect when publish lands — there is nothing to re-request.
  • Access key format: <tenant-hash>.sql.my/<USER-CODE>. It embeds the tenant, so there is no company/book picker and no org-fetch step — fetch_organizations returns empty. The connect handler parses the tenant half with parse_tenant_id (deliberately loose: it rejects a pasted URL and requires a non-empty tenant and user segment, nothing more, because no on-premise key has ever been seen).
  • 🚨 Onboarding footgun — generating an API key locks that user out of the SQL Account UI. “Once an API Secret Key is generated for a user, that user will no longer be able to login on SQL Account directly” (setup-configuration). The customer must create a dedicated API user first and grant it AR, AP, GL/cash book, stock and maintenance rights. Keys are shown once and cannot be retrieved. The connect form carries both warnings.
  • One accounting integration per entity. Connecting SQL Account fails if the entity already has any accounting integration, in any status, other than a non-active SQL Account row. A reconnect may re-key the same company but must not move the integration to another tenant — the tenant, not the SQL user, is the company identity, and both the connect handler and activate assert it.

INTERNAL - DO NOT FORWARD — the next bullet names tofu’s own entity_integration storage columns.

  • Credential storage, split by sensitivity: the secret key goes in private_metadata (as the OauthToken’s access token), the access key in misc (EntityIntegrationMisc::SqlAccount — the signer needs both halves on every request), and internal_query_key holds the tenant hash rather than the whole access key, so two SQL users on one company collide on reconnect instead of creating a second integration. public_metadata is { provider, tenant_id, company_name, base_currency } and holds nothing credential-bearing.

Deployment Topology (hosted vs on-premise) #

Mode How the API is reached Customer burden
SQL-hosted / SQL Connect cloud api.sql.my routes to SQL’s hosted backend by access key none — generate a key
On-premise the customer installs “SQL API Service”, obtains a dedicated public IP from their ISP and forwards TCP 443 at their router significant; needs their IT and ISP

There is no vendor tunnel and no agent-initiated relay. If the customer’s PC or internet connection is down, the integration is down, so a heartbeat failure for an on-premise tenant is routinely not our fault. The connector is built around that: a transport error maps to Unexpected, and only Disconnected / Unauthorized flip a row to UNAUTHORIZED — so an unreachable on-premise tenant is never marked as having bad credentials.

⚠️ Known gap: we have only ever tested the hosted mode. Our sandbox is hosted and no on-premise key has ever been seen, so the on-premise path — the mode with the highest support burden — is documented from the vendor’s setup guide, not from observation.

APIs Required for Activation #

Two calls, the Bukku pattern — no OAuth authorize step and no org-picker call, because the access key embeds the tenant.

  1. Create API-Key Integration

    POST /api/v1/integrations/api-key
    
    • Parameters: entity_id (required): UUID of the entity

    • Request Body:

      {
        "provider": "SQL_ACCOUNT",
        "credentials": {
          "access_key": "b4d12bfda57182de84a37b6c8ddf7444.sql.my/APIUSER",
          "secret_key": "your-sql-account-secret-key"
        }
      }
      
    • Response: the created EntityIntegration in PENDING status after credential validation

    • Credential source: SQL Account desktop → Tools > Maintain User... > (a DEDICATED API user) > More > API Secret Key > Generate API Secret Key

    • Credential validation: a signed GET /currency?offset=0 with the browser User-Agent. Four rows, no blobs — deliberately not GET /profile, which returns the company logo as a base64 JPEG on every call

    • Side effect: every auto-publish flag on the entity is turned off on first connect, so nothing is written to the customer’s books before publish exists and is opted into

  2. Setup Integration (Activate)

    PUT /api/v1/integrations/{integrationId}
    
    • Parameters: integrationId (required), entity_id (required)

    • Request Body:

      {
        "status": "PENDING",
        "public_metadata": {
          "provider": "SQL_ACCOUNT",
          "tenant_id": "b4d12bfda57182de84a37b6c8ddf7444"
        }
      }
      
    • The tenant_id in the body must match the stored internal_query_key or activation is refused — there is no company picker between PENDING and ACTIVE, so this is the only place the tenant can be re-asserted

    • The probe reads company_name from GET /profile and base_currency from the "----" row of GET /currency (isocode), falling back to MYR. Both are written server-side into public_metadata; the client sends only the selector

    • 🚨 GET /profile carries no currency field at all. This is the single most-missed fact about this provider — do not “fix” the base-currency read by pointing it at /profile

The access key above is the sandbox tenant hash and is an identifier, not a secret; the secret key is a placeholder. Never paste a real secret key into these docs.

Per-Object Read Support #

The Status column is the state of the connector, not of SQL’s API. Every read below is wired. The two trait methods that still answer not_yet_implemented are fetch_payment and fetch_invoice_histories, because SQL exposes no payment or document-history endpoint; fetch_organizations answers through the shared accounting::utils::empty helper, since the access key already fixes the company. SQL’s own support is in the Notes.

Object SQL endpoint Status Notes
Accounts (CoA) GET /account, detail /{CODE} ✅ read The raw list is not a list of usable accounts. 130 sandbox rows → 85 postable after four exclusions: _XX_ account-type header rows; specialacctype DR/CR (every customer and supplier is a GL sub-ledger account in SQL); DC/CC control accounts; and any account with children, since posting to a parent is refused with 500 "There is child account for account 400-000. Posting aborted.". AccountingAccount id / code / number are all SQL’s code; the endpoint carries no active flag.
Payment methods GET /pmmethod, detail /{CODE} ✅ read code is the bank/cash GL account code (310-001, 320-000); journal ∈ BANK / CASH / GENERAL; name joined from /account.description; currencycode is per method. ⚠️ bankid and accno are null on every row, so a statement’s account number cannot be matched here — the user picks the bank account.
Items GET /stockitem, detail /{DOCKEY} ✅ read Informational only: publish codes lines by GL account and omits itemcode, and the CSV templates have no item column. ⚠️ itemtype’s value set is unknown, so the Service / NonInventory split sits behind a tolerant decoder.
Tax rates GET /tax, detail /{AUTOKEY} ✅ read No compound taxes and no tax groups — one component per rate, no effective-% synthesis. Two traps: taxrate is a string that may be "10%", "A" (variable — the operator is prompted at entry), "8%;6%" (a rate schedule, service tax moved 6% → 8%) or ""; and taxtype is a packed integer whose high two bytes give the sales (0x4012) / purchase (0x7011) family, which SQL does enforce — a sales code on an AP document returns 500 "Invalid Tax Code \"ST\"". A blank tax is legal for a no-tax line.
Tags GET /project, GET /area, GET /agent ✅ read Three independent dimensions, merged into one list as three synthetic non-selectable roots with dimension-prefixed child ids — project:<code>, area:<code>, agent:<code> — so the write side can reverse them. Project reaches line level on every document family; Area and Agent only on cash-book and journal lines, so they ship as Transaction (header only). Each dimension’s "----" default row is hidden from the picker and re-sent when nothing is chosen.
Locations GET /location, detail /{CODE} ✅ read A real first-class object with addresses, not the usual stub — 6 rows in the sandbox; AccountingLocation.id is SQL’s code.
Contacts GET /customer + GET /supplier, detail /{CODE} ✅ read + write-back Split object with byte-identical schemas — one decoder, two calls, merged. ⚠️ Addresses are not on the header; they live in sdsbranch[]. ⚠️ code is silently truncated to 10 characters (boundary confirmed: 10 ✓, 11 ✗) and shares one identifier space with GL account codes. ⚠️ SQL does not reject a wrong-side contact on posting — tofu’s role flags are the only guard. Write-back covers code, tin, brn, idtype / idno.
Currencies GET /currency, detail /{CODE} ✅ read Keyed by isocode. The base currency is the row whose code is "----" — filter that sentinel out of the user-facing list or the review form offers a currency literally named ----. buyingrate and sellingrate are separate; which applies per direction is unconfirmed.
AP bills GET /supplierinvoice (+ detail) ✅ read Read the ledger family, write the stock family. /purchaseinvoice (source document: qty, unit price, discount, e-Invoice block) and /supplierinvoice (AP-ledger projection: duedate, paymentamt, outstanding) are the same document under the same dockey. outstanding == "0" is the paid signal and is detail-only. The AP list mixes document types — PI-* bills and CP-* cash purchases, told apart by fromdoctype — so external ids are type-prefixed. cancelled: true → Deleted.
AR invoices GET /customerinvoice (+ detail) ✅ read Assumed symmetric with AP (salesinvoice → customerinvoice at one dockey) — ⚠️ verified on the AP side only; probe before the AR publish task.
Direct expenses GET /paymentvoucher (+ detail) ✅ read SQL’s cash-book voucher. ⚠️ The endpoint mixes directions — doctype PV (money out) and OR (official receipt, money in) — so reads must filter on doctype, or receipts land in tofu as expenses. The paid-from account comes from the payment method; external ids are type-prefixed (pv:<dockey>).
Bank statements — ❌ not read SQL exposes no bank-statement or bank-feed read endpoint.
Attachments — ❌ unsupported See Attachments below.
Company profile GET /profile ✅ read (company name) Used by activate. ⚠️ No base currency and no SST / service-tax registration numbers — only brn / brn2. Returns the company logo as a base64 JPEG on every call, which is why the heartbeat does not use it.
Payment terms GET /terms ⏳ not wired Not a canonical tofu object; it lets us derive duedate from a terms code. SQL also derives duedate itself when it is omitted on create.

The most surprising fact in the set, worth stating on its own: GET /account returns one sub-ledger row per customer and supplier (specialacctype DR / CR, verified 13/13 and 8/8 in the sandbox) — in SQL Account a contact is a GL account. That shared identifier space is also why a contact code is capped at 10 characters.

SQL Account Account Type Mapping #

SQL’s acctype has 18 values. The labels below are each type’s own _XX_ header-row description from the live capture, not inferred from the two letters:

acctype SQL’s own label tofu AccountClass
CA CURRENT ASSETS Asset
FA NON-CURRENT ASSETS Asset
OA OTHER ASSETS Asset
IV INVESTMENT Asset
CL CURRENT LIABILITIES Liability
LT NON-CURRENT LIABILITIES Liability
OL OTHER LIABILITIES Liability
CP EQUITY Equity
RE RETAINED EARNING Equity
RV RESERVE Equity
SL REVENUE Revenue
SA REVENUE ADJUSTMENT Revenue
OI OTHER INCOME Revenue
EO EXTRA ORDINARY INCOME Revenue
CO COST OF GOODS SOLD Expense
EP EXPENSES Expense
TX TAXATION Expense
AP APPROPRIATION ACCOUNT Expense
(unknown) — Expense, with a warning logged; never panic

Notes:

  • ⚠️ Four codes do not mean what their letters suggest, and three of them change the account’s class: AP is Appropriation, not Accounts Payable; IV is Investment, not Inventory; RV is Reserve, not Revenue; EO is Extra Ordinary Income, not an expense. Anyone extending this mapping must read the _XX_ header row, not the letters.
  • specialacctype: only BA overrides the class, to (Asset, Bank). CH (cash) keeps its acctype class, takes the cash compatibility branch and is depositable. SQL diverges from AutoCount here deliberately — DP is an asset for a supplier prepayment but a liability for a customer one, and OS / CS / MC are P&L trading rows — so everything except BA falls through to acctype.
  • specialacctype ' ' is two literal spaces, not empty and not null — a decode trap.
  • The four exclusions from the accounts row above apply before anything reaches tofu’s chart of accounts.

Status Codes Are Inverted #

The single most useful table in this section for a future debugger. Verified live; the connector’s decode_response implements exactly this, and each mapping exists to keep a specific failure out of the wrong handler.

Status What it actually means Connector maps it to
200 success — JSON for GET/POST, but the literal body OK as text/html for DELETE the JSON body
400 overloaded: an empty collection or past the last page ("No record found."), or a genuine not-found ("Supplier Invoice: \"385\" not found") — told apart by the message empty result, or NotFound
401 / 403 auth failure ("Invalid signature") — and, misleadingly, a POST that omitted Content-Type: application/json Unauthorized
404 + HTML SQL’s edge WAF blocked the request before the API saw it (non-browser User-Agent), returned as an IntraWeb HTML page. Never NotFound — always a transport or configuration fault Unexpected
500 normal validation / business-rule failure, with a human-readable English message. Must not be retried BadRequest, so it never reaches the shared 5xx retry loop
other 5xx a genuine outage ExternalServerError (retryable)
transport error the tenant’s host is unreachable — routine for on-premise Unexpected, so the heartbeat does not mark the row UNAUTHORIZED

There is no machine-readable error code anywhere — only an English message, occasionally a raw Delphi internal error ("Could not convert variant of type (Null) into type (Integer)" for a bad tax code). Text matching is the only option, which is why the tofu validator will carry so much of the weight for this provider.

Calculation Config #

TaxCalculationMode = LineByLine — every line carries its own taxamt, localtaxamt, taxableamt and amountwithtax. Inclusive vs exclusive is per line, via the line’s taxinclusive boolean, not per document. No compound taxes and no tax groups. Every amount is duplicated as a local* base-currency figure that tofu must compute and keep consistent itself — SQL accepted a header total that disagreed with its lines, so it will not check them for us. Decode any decimal precision, encode 2dp. Registered as AccountingTarget::SqlAccounting, which already ships for CSV export (SQLAccApBill / SQLAccArInvoice / SQLAccDirectExpense / SQLAccBankStatement converters).

SQL Account Publish #

Five types publish. A key has no scopes, so no reconnect was needed when publish shipped, and auto-publish starts off on first connect (should_disable_auto_publish_on_first_connect). should_delete_before_publish is true: every republish is delete + create.

Type SQL document Create Republish Delete Paid / applied Attachments
AP bill Purchase Invoice POST /purchaseinvoice delete + create DELETE /purchaseinvoice/{dockey} refused (DOCUMENT_PAID_CANNOT_MODIFY) ❌
AR invoice Invoice POST /salesinvoice delete + create DELETE /salesinvoice/{dockey} refused ❌
AP credit note Purchase Returned POST /purchasereturned delete + create DELETE /purchasereturned/{dockey} refused when unappliedamt < docamt (CREDIT_NOTE_APPLIED_CANNOT_MODIFY) ❌
AR credit note Credit Note POST /salescreditnote delete + create DELETE /salescreditnote/{dockey} refused when unappliedamt < docamt ❌
Direct expense Payment Voucher (PV) POST /paymentvoucher delete + create DELETE /paymentvoucher/{dockey} refused ❌
Bank statement — ❌ deferred by decision — see below — — — —

Write the stock family, read the ledger family. /purchaseinvoice and /supplierinvoice are one document under one dockey; the write goes to the stock family and the payment state is always read from the ledger projection (/supplierinvoice, /customerinvoice, /suppliercreditnote, /customercreditnote). outstanding is detail-only. Direct expenses read back as themselves, with a type-prefixed external id (pv:<dockey>) because /paymentvoucher also returns OR receipts.

Request-shape rules that every type shares, each one live-confirmed and each one a silent corruption if missed:

  • submissiontype: 0 explicitly, on every type — omitting it lets the contact’s e-Invoice default through (see the e-Invoice bullet in the Notes).
  • tax explicitly, "" for no tax. A line sent without tax had a company default applied (SV, 8 %), turning a 100 line into 108.
  • changed: true on header and every line; dtlkey omitted or -1 on insert; line seq steps by 1000; updatecount present on PUT (any value).
  • docno blank — SQL auto-numbers, so the customer’s numbering format is preserved.
  • Dimensions take "----" when nothing is chosen.
  • amount / docamt are omitted; qty, unitprice and disc are sent and SQL computes the totals, which removes the total-mismatch class entirely. A line with no quantity sends amount net of discount instead.
  • Amounts are strings, encoded at 2dp; itemcode is never sent, lines are coded by GL account.

Read-back and compare is mandatory, not belt and braces. SQL truncates over-long fields and accepts a document with no lines, both silently with a 200, so publish compares field values and counts lines, raising FIELD_TRUNCATED on a mismatch.

Refuse on paid, never fall back to PUT. SQL accepts a PUT on a knocked-off document but refuses to DELETE it. Because a republish is delete + create, a paid or applied document is refused up front — the ledger projection is read before anything is written — rather than updated in place, which would leave the payment knocking off a stale amount.

Last write wins. updatecount is mandatory on PUT and its value is ignored, so the documented optimistic-concurrency error never fires: a republish silently overwrites a concurrent edit made in the SQL Account desktop app, and neither side is told.

Credit notes publish unapplied. Probed 2026-10-19: POST /salescreditnote with an sdsknockoff[] naming an unpaid invoice returns 200 and silently drops the knock-off (unappliedamt == docamt, no knock-off rows). original_invoice_number / original_invoice_date therefore cannot be carried — the customer applies the note in SQL Account. User-visible, and documented in the customer-facing guide.

Attachments are not supported — eStream support confirmed the REST API cannot receive attachment bytes. The capability is off (.without_publish_attachments()), so accounting-sync skips the upload step; a direct attempt answers IntegrationError::Unimplemented.

Error keys (accounting/sql_account/model/error.rs, mirrored by the sqlAccount object in config/i18n/en/error.json, with a parity test over both): NOT_AUTHORIZED_RECONNECT, SERVER_UNREACHABLE, TOO_MANY_REQUESTS, DOCUMENT_NOT_FOUND, RECORD_CHANGED_RECONCILE, SUPPLIER_REQUIRED, CUSTOMER_REQUIRED, SUPPLIER_NOT_FOUND, CUSTOMER_NOT_FOUND, LINE_ACCOUNT_REQUIRED, ACCOUNT_NOT_FOUND, TAX_CODE_NOT_FOUND, PAYMENT_METHOD_REQUIRED, TAG_NOT_PUBLISHABLE_ON_LINE, DOCUMENT_PAID_CANNOT_MODIFY, CREDIT_NOTE_APPLIED_CANNOT_MODIFY, DOCUMENT_SUBMITTED_EINVOICE, FIELD_TRUNCATED, ATTACHMENT_NOT_SUPPORTED. Validator rules live in tofu-validator-core and carry their own invoiceValidation / directExpenseValidation keys.

Direct expense specifics: Spend only; doctype is omitted and defaults to PV (/paymentvoucher and /receiptvoucher are the same collection, and a doctype you do send is stored verbatim even when it contradicts the endpoint); the paid-from account is paymentmethod, a code from GET /pmmethod, which sets journal itself; the payee is free text (companyname), so a scanned receipt needs no registered supplier; chequenumber comes from a cheque payment. ⚠️ Each payment method carries its own currencycode and SQL does not enforce the match — a USD method on an MYR document was accepted — so the tofu validator owns that rule. Unlike the invoice endpoints, /paymentvoucher rejects a document with no lines.

Bank statement publish is deferred by decision, not by omission. It is technically possible — one paymentvoucher (PV) or receiptvoucher (OR) per transaction — but a published statement line lands in the same cash book as a published direct expense with nothing distinguishing the two, so the two features overlap and can double-post one real payment. The existing CSV template (ENG-8455) remains the path for bank statements, and SqlAccount is deliberately absent from the BankStatement arm of supports_publish_for_type and of the webapp’s supportsPublishForType. Do not re-research this.

No deep links, on publish either. SQL Account is a desktop application, so a published document is shown as its number in SQL’s own nouns (“Purchase Invoice”, “Invoice”, “Credit Note”, “Purchase Returned”, “Payment Voucher”), never as a link.

On-premise reachability applies to writes too, and is more visible there because a publish is a user waiting on a reply. The tenant is typically an office desktop running the SQL API Service, so “unreachable” is a routine state: it maps to SERVER_UNREACHABLE, leaves the integration connected and is never a credentials failure.

SQL Account Notes #

  • Capabilities: read_only_access().with_contact_tax_identification_number_sync().with_create_contacts().with_direct_expense_self_prompt().with_have_header_level_tags().with_publish().with_credit_note_publish().without_publish_attachments() — can_sync / can_import are false, and so is can_update_contacts: contact create and TIN sync are the only contact writes, there is no general contact update. Self-prompt is on for AP bills, AR invoices and direct expenses; header-level tags are on. Tag auto-assign stays at the read-only default (on), because both scope and level are known for all three dimensions. without_publish_attachments() is load-bearing: without it accounting-sync uploads, the connector answers Unimplemented, and a document that was in fact created is marked failed.
  • Self-prompt also needs the Python allowlist. with_direct_expense_self_prompt() only takes effect because SQL_ACCOUNT is in _SUPPORTED_PROVIDERS in libs/python/bonsai-hinoki/.../direct_expense_knowledge_setup_orchestrator.py. Without that entry the capability silently no-ops in production while the webapp button still renders.
  • Feature flag: sql_account_integration, canary tier. Without it the provider does not appear in the integration picker.
  • Env var: SQL_ACCOUNT_API_BASE_URL (default https://api.sql.my). Deliberately not in the deployment manifests: the gateway host is the same for every tenant, and five of the seven providers that declare this key keep it as a local override only. The constant is the production value.
  • No per-document deep links. SQL Account is a desktop application, so no document route exists. The webapp links to connect.sql.com.my, the cloud client’s company picker, flagged listing-only (as AutoCount is), and shows the document number as text (“Supplier Invoice PI-00123 in SQL Account”) for the user to search on arrival. An on-premise customer has no cloud client, so the link does nothing for them; landing in their own company would need the picker URL’s client token, which is in no API response.
  • Status labels use SQL’s nouns — “Supplier Invoice” (AP), “Customer Invoice” (AR), “Payment Voucher” (direct expense). Malaysian bookkeepers do not say “bill” or “expense”.
  • 🚨 Validation is silent truncation, not rejection — the governing principle for this provider. Confirmed live: an 11-character contact code returns 200 and persists 10; a header docamt: "9999" against a single "100" line is accepted; an area / agent / project code that exists in no master list is stored verbatim; docno has no length limit at all. SQL will not tell us when input is invalid — it quietly mangles or accepts it. Every field-length, total-consistency and reference rule therefore belongs in the tofu validator, and publish must read back and compare.
  • Everything numeric is a string, in both directions, with no fixed decimal places on responses ("5010", "15487.5" — not "5010.00"), while requests use 2dp. Decode string → Decimal, never f64. Meanwhile status, idtype, updatecount, taxtype and lastmodified are integers, and cancelled, taxinclusive, isactive and changed are booleans.
  • List responses omit fields the detail returns — confirmed on three objects: outstanding on documents, sdsbranch[] addresses on contacts, and uom / refcost / refprice / barcode on items. Never treat a list row as a complete record.
  • Pagination is ?offset=N, max 50 rows per GET. End of pages is 400 {"error":{"message":"No record found."}}, not an empty list, and count is rows in this page, not the total.
  • No date-range filtering anywhere. docdate is exact-match only; docdate>=, docdate_from/_to and a..b all fail. Worse, an unknown query parameter is silently ignored and returns the whole collection with a 200 — so every filtered read needs a test asserting the filter actually narrowed the result. fetch_*_recent must therefore page the whole ledger 50 rows at a time with a client-side cutoff and a page cap. For an on-premise tenant that is politeness as much as performance: the backend is someone’s office desktop.
  • Rate limits are undocumented and nothing throttled across ~60 calls. IntegrationRetryAfterCache is wired defensively (429 → TooManyRequests with a 1-second floor when no Retry-After is present) and concurrency is held to 1–2.
  • updatecount is mandatory on PUT but its value is ignored — 0, 1 and 99 all returned 200, while omitting it returns 500 "Field (updatecount) not found.". The documented optimistic-concurrency error never fires, so writes are last-write-wins and offer no protection against clobbering a concurrent desktop edit — a risk to record, not a mechanism to rely on. Confirmed for supplierinvoice; paymentvoucher carries a real updatecount, so re-probe per type. Also: inserts omit dtlkey (or send -1), line seq increments by 1000 (a Delphi convention), and a changed / dirty flag must be set on headers, lines and master records.
  • 🚨 e-Invoice coupling (LHDN MyInvois). submissiontype defaults from the contact record, and a published stock-family document may be submitted to the Malaysian revenue authority on creation, after which irbm_uuid / irbm_status are set and the document is likely immutable. The decided safety posture is to send submissiontype: 0 (None) explicitly on every publish and let the customer submit from SQL Account themselves. This is recorded here, in the read-only section, because it is a safety property of the connector rather than a publish feature.
  • Withholding tax (GET /whtax, plus whtax* fields on purchase lines) is out of scope — a known gap.
Attachments are not supported #

Answered by eStream support: the REST API cannot receive attachment bytes. attachments appears on 112 objects, including nonsense carriers like area and agent; every example value is exactly 64 base64 characters decoding to 48 random bytes — uniformly masked blob/memo columns, not a file API. Every captured record had attachments: null. Publish ships without pushing the source document (.without_publish_attachments(); a direct attempt answers IntegrationError::Unimplemented). The API does expose a server-rendered document PDF (the detail URL with Content-Type: application/pdf;template=<report_name>), but that is SQL printing its own document, not our scan. Closed — do not re-ask.

Known gaps and NEEDS CONFIRMATION #

Each carries its owner, so nobody re-researches a question that is already queued.

  • On-premise is untested by us — our sandbox is hosted and no on-premise access key has ever been seen. If an on-premise key is not of the form <hash>.sql.my/<USER-CODE>, the connect-form format check could reject a legitimate customer (mitigated: the check is deliberately loose). SQL support.
  • Is the API a paid add-on? Is there a per-company API-user cap? Minimum stated version 5.2025.1038.875; our sandbox runs 6.2026.1092.899. Commercial; gates go-to-market, not code. SQL support.
  • Rate limits and Retry-After semantics — undocumented, nothing observed. SQL support.
  • Is GET /version authenticated? If it is, the heartbeat should use it instead of /currency; if it is not, a heartbeat on it would never notice a revoked key. SQL support.
  • Which element of a "8%;6%" tax-rate schedule is current? We take the first. SQL support.
  • buyingrate vs sellingrate per direction (AP vs AR) — the sandbox has them equal on every currency, so probing gives no signal. Accountant.
  • sdsbranch[].branchtype value set, and which branch is the billing address. Sandbox probe.
  • exempted_taxrate / exempted_taxamt semantics — present but null / "0" on every ordinary document. Needs a document that uses one.
  • itemtype value set. Sandbox probe.
  • The AR pair (salesinvoice → customerinvoice at one dockey) is assumed symmetric with AP and unverified. Probe before the AR publish task.
  • Document status integer value set — only 0 has ever been seen. Sandbox probe.
  • MYR 5-sen rounding behaviour, and whether more than 2 decimals are accepted. Sandbox probe.
  • A durable sandbox key in Doppler. Keys are one-shot and ours was never retained; without it none of the probe-shaped questions above can be answered. Highest-leverage missing resource for this provider.

For the connector deep dive, the deployment-topology triage fork and the support runbook, see the SQL Account Integration guide.

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.