Integration APIs #
This document provides basic information about the available integrations in tofu and the APIs required to activate them.
Overview #
tofu supports two main types of integrations:
- Accounting Integrations - Connect to accounting software for invoice synchronization
- Trigger Integrations - Connect to document storage systems for automated document processing
At a minimum integrations of any type require two steps to fully integrate.
- OAuth Integration Flow
- Integration Setup
tofu AccountClass Types #
tofu uses a unified AccountClass enum to categorize all accounting accounts across different providers:
- Asset: Assets (current, fixed, inventory, etc.)
- Liability: Liabilities (current, non-current, payables, etc.)
- Equity: Equity accounts (share capital, retained earnings, etc.)
- Revenue: Revenue/Income accounts (sales, other income, etc.)
- Expense: Expense accounts (costs, overheads, depreciation, etc.)
- Bank: Bank accounts
- CreditCard: Credit card accounts
Available Integrations #
Xero (Accounting Integration) #
Xero is a cloud-based accounting software that allows you to sync invoice data and manage your financial records.
Provider Code: XERO
APIs Required for Activation #
-
Get OAuth Authorization URL
POST /api/v1/integrations/oauth/authorize- Parameters:
entity_id(required): UUID of the entityprovider:XEROredirect_url(required): URL to redirect after OAuth flow
- Response: Returns authorization URL for Xero OAuth flow
- Parameters:
-
Fetch Available Organizations
GET /api/v1/integrations/{integrationId}/accounting-organizations- Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
- Response: List of available Xero organizations/tenants
- Parameters:
-
Setup Integration
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "XERO", "organization_id": "your-xero-org-id", "pays_tax": true } }
-
Additional Xero APIs #
- Chart of Accounts:
GET /api/v1/integrations/{integrationId}/accounting-accounts - Contacts/Vendors:
GET /api/v1/integrations/{integrationId}/accounting-contacts - Tax Rates:
GET /api/v1/integrations/{integrationId}/accounting-tax-rates - Currencies:
GET /api/v1/integrations/{integrationId}/accounting-currencies
Xero Account Type Mapping #
The following table shows how Xero account types are mapped to tofu’s internal AccountClass:
| Xero Account Type | Description | tofu AccountClass |
|---|---|---|
| BANK | Bank account | Bank |
| CURRENT | Current Asset account | Asset |
| CURRLIAB | Current Liability account | Liability |
| DEPRECIATN | Depreciation account | Expense |
| DIRECTCOSTS | Direct Costs account | Expense |
| EQUITY | Equity account | Equity |
| EXPENSE | Expense account | Expense |
| FIXED | Fixed Asset account | Asset |
| INVENTORY | Inventory Asset account | Asset |
| LIABILITY | Liability account | Liability |
| NONCURRENT | Non-current Asset account | Asset |
| OTHERINCOME | Other Income account | Revenue |
| OVERHEADS | Overhead account | Expense |
| PREPAYMENT | Prepayment account | Asset |
| REVENUE | Revenue account | Revenue |
| SALES | Sale account | Revenue |
| TERMLIAB | Non-current Liability account | Liability |
Note:
- tofu’s AccountClass also includes
CreditCard, but Xero doesn’t have a specific credit card account type - Unknown account types default to
Expensewith a warning logged
Account Type Filtering #
The chart of accounts API supports filtering by account type:
GET /api/v1/integrations/{integrationId}/accounting-accounts?account_types=BANK,EXPENSE
- Parameters:
account_types: Comma-separated list of AccountClass values to filter- Valid values:
ASSET,LIABILITY,EQUITY,REVENUE,EXPENSE,BANK,CREDIT_CARD - If not provided, returns all account types
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
classfield - Filtering uses SQL-like syntax:
AccountType = 'Bank'
Bukku (Accounting Integration) #
Bukku is a Malaysian cloud accounting platform. Unlike OAuth-based accounting connectors, Bukku uses an API Access Token and company subdomain.
Provider Code: BUKKU
Authentication: API key (no OAuth flow)
APIs Required for Activation #
-
Create API-Key Integration
POST /api/v1/integrations/api-key-
Parameters:
entity_id(required): UUID of the entity
-
Request Body:
{ "provider": "BUKKU", "credentials": { "access_token": "your-bukku-access-token", "subdomain": "your-company-subdomain" } } -
Response: Returns the created
EntityIntegrationinPENDINGstatus after credential validation -
Credential source: Bukku Control Panel → Integrations → API Access
-
Subdomain: URL prefix from
https://{subdomain}.bukku.my
-
-
Setup Integration (Activate)
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "BUKKU", "subdomain": "your-company-subdomain" } }
-
Bukku Notes #
- Read-only integration:
can_publishandcan_importare false; reference-data sync is implemented incrementally. - One accounting integration per entity: Creating Bukku fails if another accounting integration is already connected.
- Credential validation: Backend calls
GET /contacts?page=1&page_size=1withAuthorization: Bearer <access_token>andCompany-Subdomain: <subdomain>.
For end-user setup steps, see the Bukku Integration guide.
FreeAgent (Accounting Integration) #
FreeAgent is a UK-first cloud accounting product (NatWest-owned) for freelancers, contractors, and micro-businesses. API v2.
- Provider Code:
FREE_AGENT - Authentication: OAuth2 (authorization-code); no granular scopes (access follows the connecting user’s role).
Auth / Connection Model #
- Authorize:
…/v2/approve_app(the authorize URL carries onlyresponse_type,client_id,redirect_uri,state— no scope string). - Token exchange:
POST …/v2/token_endpointusing HTTP Basic auth (client_id = username, client_secret = password); form bodygrant_type=…&code=…&redirect_uri=…. - Rotating refresh tokens: the refresh grant returns a new access token AND a new refresh token — always persist the latest. There is no documented revoke endpoint; disconnect just drops the stored token.
- API version header: requests pin
X-Api-Version: 2024-10-01; unversioned requests silently follow “today’s” behavior. Bank transaction explanation and bank statement calls pin2026-09-01per request, because explanations switch to a multi-attachment shape on 2026-12-01. - User-Agent: FreeAgent rejects any request without a
User-Agentheader ("User agent http header not set"), so the HTTP client pins one. - Sandbox vs production host: selected by
ENVIRONMENT_NAME(mirrors QBO) —production→https://api.freeagent.com, everything else (dev/preview/dev_local) →https://api.sandbox.freeagent.com. Overridable per-URL viaFREEAGENT_API_BASE_URL/FREEAGENT_AUTH_URL/FREEAGENT_TOKEN_URLor the task-local runtime config (.config.yaml). - Credentials:
FREEAGENT_CLIENT_ID/FREEAGENT_CLIENT_SECRET, redirect viaOAUTH_REDIRECT_URL. One dev-dashboard app works for both sandbox and prod (the account you authorize picks the environment). - Multi-org: one authorization = one company (repeat authorization for more, like Xero/QBO).
- 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 #
-
Get OAuth Authorization URL —
POST /api/v1/integrations/oauth/authorize(entity_id,provider: FREE_AGENT,redirect_url). -
Setup / Activate —
PUT /api/v1/integrations/{integrationId}. FreeAgent scopes one token to one company, so the activate probe isGET /v2/company(no org-picker step); the tenant key is the numericcompany_idfrom that response (company_urlis…/v2/companyfor every company, so it can’t tell two companies apart). Public metadata also storessubdomain,company_type,sales_tax_effective_date,ec_vat_reporting_enabledanduser_url(the connecting user, fromGET /v2/users/me; direct expenses are recorded against them). Rows activated beforecompany_idexisted 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 throughsupports_publish_for_type(Rust) andsupportsPublishForType(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_integrationenabled on the organization. - Resource ids are bare, not URLs. FreeAgent identifies resources by full URI, but the id must survive single-segment internal paths (
/accounting-invoices/{id},/contact-knowledge/{id}), so invoice/bill/expense/contact external ids are stored as the bare last URL segment (e.g.743559) and the fetch/write layer rebuilds the full API URL. - Decode-hardening: all decoders are open-world (
#[serde(default)], tolerate unknown fields, no panic on unknown enum). Notecontact_name_on_invoicesis a boolean toggle (not a name). - Known gaps: attachment download-by-id path is not yet path-safe (the attachment id still embeds a URL); JPY zero-decimal + calculator reconciliation fixtures pending a sandbox probe; no invoice-history endpoint; production app / high-volume sign-off is a dev-portal chore.
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
referenceis unique, so an AR retry fails withREFERENCE_ALREADY_USEDinstead. - 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 netprice, 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_amountin the base currency, which tofu has no exchange rate for), so the connector refuses it withFOREIGN_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_statuson UK companies,sales_tax_statusfor 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:
- Validate (
validate_bank_statement_for_publish): the group’s account must resolve to a/v2/bank_accounts/:idURI with the bank-statement slot (a legacy750-Nid 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 withBANK_ACCOUNT_VALIDATION_UNAVAILABLE. - Fitid (
assign_transaction_ids): every top-level line getstofu:<account group id>:<date>:<amount>:<n>in itsexternal_id(ncounts 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: afterBANK_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). - 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_CHANGEDbefore 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). - Upload all top-level lines in one
POST /v2/bank_transactions/statement?bank_account=<uri>(dated_on, signedamount,description,fitid,transaction_typeDEBIT/CREDIT). The response is an empty200with no ids. FreeAgent dedups onfitid(sandbox-verified: a re-upload of the same fitid adds nothing, and a fitid whose transaction was deleted imports again). - 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. - Explain lines that have a contra account (
bank_line_contra_*helpers) withPOST /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 sendec_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. - 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.
- 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-tenantcommonendpoint, so any BC tenant can consent). - Scope:
https://api.businesscentral.dynamics.com/.default offline_access—offline_accessis mandatory; without it Entra issues no refresh token. - Token lifecycle (support-relevant): access token ~1 h; the refresh token is 90-day rolling and rotates on every use. It is revoked when the connecting user is disabled, resets their password, or an admin revokes consent — the heartbeat then flips the integration to unauthorized and the customer must reconnect. This is the #1 expected support scenario for this connector.
- Composite “organization”: unlike Xero’s single org-picker, BC addresses data by Entra tenant × environment × company, so activation has two picker levels (environment, then company). The selector is stored as
{tenantId}:{environmentName}:{companyId}. - Company-scoped URLs: every data call hits
{host}/v2.0/{tenant}/{environment}/api/v2.0/companies({companyId})/<entity>. Environment discovery isGET {host}/environments/v1.2(bare host, NOT under the API base). - Read-replica reads: all sync GETs (including attachment downloads) send
Data-Access-Intent: ReadOnly. - Rate limits: 6,000 requests / 5 min, 5 concurrent. 429 responses carry no
Retry-Afterheader, so the client applies a conservative 30 s fallback backoff viaIntegrationRetryAfterCache. - Credentials:
DYNAMICS_BC_CLIENT_ID/DYNAMICS_BC_CLIENT_SECRET, redirect viaOAUTH_REDIRECT_URL. Host/auth/token URLs are overridable viaDYNAMICS_BC_API_BASE_URL/DYNAMICS_BC_AUTH_URL/DYNAMICS_BC_TOKEN_URLor the task-local runtime config (.config.yaml).
APIs Required for Activation #
-
Get OAuth Authorization URL
POST /api/v1/integrations/oauth/authorize- Parameters:
entity_id(required): UUID of the entityprovider:DYNAMICS_BCredirect_url(required): URL to redirect after OAuth flow
- Response: Returns the Microsoft Entra ID authorization URL
- Parameters:
-
Fetch Available Organizations
GET /api/v1/integrations/{integrationId}/accounting-organizations- Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
- Response: List of environment × company pairs. The backend enumerates environments (
GET /environments/v1.2), then companies per environment; each organization id is the composite selector{tenantId}:{environmentName}:{companyId}.
- Parameters:
-
Setup Integration
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "DYNAMICS_BC", "tenant_id": "f1e2d3c4-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "environment_name": "production", "company_id": "6b1c31d8-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } } -
The activate request carries only the selector;
company_name,base_currency, andmulti_currency_enabledare resolved from Business Central server-side. -
Activation also seeds the company’s tax codes as internal, editable tofu tax rates (see Tax rates below).
-
Per-Object Support #
| Object | BC entity (API v2.0) | Support | Notes |
|---|---|---|---|
| Accounts (CoA) | accounts |
✅ read | GET-only. Only non-blocked accountType == "Posting" rows are ingested (Heading/Total dropped). The CoA yields no Bank/CreditCard classes. |
| Items | items |
✅ read | type Inventory / Service / Non-Inventory; priceIncludesTax is per item. |
| Tax rates | taxGroups |
⚠️ partial | BC exposes no tax percentage anywhere in v2.0 (only a read-only, server-computed taxPercent on document lines). Tax codes are ingested as 0% shells and seeded at activation as internal, editable tofu tax rates (AutoCount pattern — usesInternalTaxRates); the customer fills in the percentages in tofu. On VAT tenants the same entity returns VAT Product Posting Groups. |
| Tags | dimensions + dimensionValues |
✅ read | GET-only; dimension → non-selectable parent, value → selectable child; tags apply at header AND line level. BC exposes no per-dimension scope/level. |
| Locations | — | ❌ not synced | fetch_location is unimplemented; line locationId is not resolved against a location master. |
| Contacts | customers + vendors |
✅ read + write-back | Split collections (QBO pattern) merged with fixed roles (vendor → supplier, customer → customer); fully-blocked parties dropped; a dual-role company is two BC records (no dedupe). The CRM contacts entity is NOT mapped. Write-back: create supplier/customer + update taxRegistrationNumber (TIN). |
| Currencies | currencies (+ currencyExchangeRates read-only) |
✅ read | The local currency (LCY) is NOT a row in currencies — it comes from generalLedgerSetup.localCurrencyCode. amountDecimalPlaces is a string (e.g. "2:2"). |
| AR invoices | salesInvoices (+ salesInvoiceLines via $expand) |
✅ read | Aggregate entity — listing filters status ne 'Draft'; server-computed net* amounts are trusted. |
| AP bills | purchaseInvoices (+ purchaseInvoiceLines via $expand) |
✅ read | Same aggregate/status caveats; by-contact filtering keys on vendorId (AP) / customerId (AR). |
| Direct expenses | purchaseInvoices with lineType == "Account" lines |
⚠️ partial | BC has no first-class expense entity — non-draft purchase invoices are classified client-side into the direct-expense feed (and excluded from the AP-bill feed). |
| Bank statements | — | ❌ unsupported | bankAccountLedgerEntries does not exist in API v2.0; bankAccounts is a master list only. |
Attachments: an invoice/bill’s documents are read from two collections (attachments and documentAttachments) plus the BC-rendered pdfDocument. The stored attachment id is prefixed (att: / docatt: / pdf:) and download routes by prefix to attachments({id})/attachmentContent, documentAttachments({id})/attachmentContent, or pdfDocument/pdfDocumentContent. Attachment upload/delete is not supported (read-only connector).
Business Central Account Category Mapping #
BC’s category field (“G/L Account Category”) maps onto tofu’s AccountClass:
| BC Account Category | tofu AccountClass |
|---|---|
| Assets | Asset |
| Liabilities | Liability |
| Equity | Equity |
| Income | Revenue |
| Cost of Goods Sold | Expense |
| Expense | Expense |
Note:
- Blank/unknown categories default to
Expensewith a warning logged. - Only
accountType == "Posting"rows are ingested;Heading/Total/Begin Total/End Totalrows are dropped. - The BC chart of accounts never yields
Bank/CreditCardclasses — bank accounts live in the separatebankAccountsentity, which is not ingested.
Business Central Notes #
- Read-only + contact write-back:
read_only_access().with_create_contacts().with_direct_expense_self_prompt().with_have_header_level_tags()—can_publish/can_sync/can_importare false. Self-prompt is ON for AP bills, AR invoices, AND direct expenses; header-level tags are ON. Contact create/update (TIN) is the only write to BC. - Feature flag: requires
dynamics_bc_integrationenabled on the organization before the provider appears in the UI. - Known gaps / runtime notes:
- Sales/purchase invoices are aggregate entities with a shared status enum (
" ",Draft,In Review,Open,Paid,Canceled,Corrective); a posted document’siddiffers from itssystemId. - VAT is computed per document per VAT-Identifier with a manual tolerance, so server
net*totals are trusted rather than recomputed; opt-in invoice rounding can add an extra G/L line;pricesIncludeTaxis per-document. - Enum/format quirks:
blockedis a string enum (with a blank member) on customers/vendors but a boolean on items/accounts;amountDecimalPlacesis a string;Commentlines carry no amounts. - All decoders are open-world (live payloads exceed the documented schema; vNext adds enum members such as
Allocation Account). - Localization drift: US (Sales Tax) vs EU/UK (VAT) tenants populate fields differently;
taxTypefollows the tenant’sGeneralLedgerSetup.UseVat(). - Pagination follows the server’s absolute
@odata.nextLink, validated against the API host before following (SSRF guard), with a defensive 50-page cap per call. - Tag auto-assign: BC dimensions expose no scope/level, so per the ENG-5093 pattern AI tag auto-assign should be OFF, but the capabilities arm currently leaves it at the read-only default (ON) — pending reconciliation.
- Sandbox-dependent, still unconfirmed:
Retry-Afterpresence on 429;lastModifiedDateTime$filterbehavior; whether LCY ever appears incurrencies; G/L Setup rounding defaults / VAT Rounding Type.
- Sales/purchase invoices are aggregate entities with a shared status enum (
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, servicesqlaccount,SignedHeaders: host;x-amz-dateand nothing else. Hand-rolled inlibs/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 bySQL_ACCOUNT_API_BASE_URLor the task-local runtime config (.config.yaml). ⚠️ Never the per-tenant<hash>.sql.myhost — it accepts the signature and then 404s with an IntraWeb HTML page. - 🚨 A browser
User-Agentis mandatory. SQL’s edge silently 404s any request whose UA is not browser-like, returning an HTML page rather than a403— 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 answers401 "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
OauthTokenwithexpires_at = i64::MAXpurely so the shared credential path works;refresh_tokenreturns the stored key unchanged andexchange_code/revoke_tokenreturn 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_organizationsreturns empty. The connect handler parses the tenant half withparse_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_integrationstorage columns.
- Credential storage, split by sensitivity: the secret key goes in
private_metadata(as theOauthToken’s access token), the access key inmisc(EntityIntegrationMisc::SqlAccount— the signer needs both halves on every request), andinternal_query_keyholds 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_metadatais{ 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.
-
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
EntityIntegrationinPENDINGstatus 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=0with the browserUser-Agent. Four rows, no blobs — deliberately notGET /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
-
-
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_idin the body must match the storedinternal_query_keyor activation is refused — there is no company picker betweenPENDINGandACTIVE, so this is the only place the tenant can be re-asserted -
The probe reads
company_namefromGET /profileandbase_currencyfrom the"----"row ofGET /currency(isocode), falling back toMYR. Both are written server-side intopublic_metadata; the client sends only the selector -
🚨
GET /profilecarries 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:
APis Appropriation, not Accounts Payable;IVis Investment, not Inventory;RVis Reserve, not Revenue;EOis Extra Ordinary Income, not an expense. Anyone extending this mapping must read the_XX_header row, not the letters. specialacctype: onlyBAoverrides the class, to(Asset, Bank).CH(cash) keeps itsacctypeclass, takes the cash compatibility branch and is depositable. SQL diverges from AutoCount here deliberately —DPis an asset for a supplier prepayment but a liability for a customer one, andOS/CS/MCare P&L trading rows — so everything exceptBAfalls through toacctype.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: 0explicitly, on every type — omitting it lets the contact’s e-Invoice default through (see the e-Invoice bullet in the Notes).taxexplicitly,""for no tax. A line sent withouttaxhad a company default applied (SV, 8 %), turning a100line into108.changed: trueon header and every line;dtlkeyomitted or-1on insert; lineseqsteps by 1000;updatecountpresent onPUT(any value).docnoblank — SQL auto-numbers, so the customer’s numbering format is preserved.- Dimensions take
"----"when nothing is chosen. amount/docamtare omitted;qty,unitpriceanddiscare sent and SQL computes the totals, which removes the total-mismatch class entirely. A line with no quantity sendsamountnet of discount instead.- Amounts are strings, encoded at 2dp;
itemcodeis 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_importare false, and so iscan_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 answersUnimplemented, 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 becauseSQL_ACCOUNTis in_SUPPORTED_PROVIDERSinlibs/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(defaulthttps://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
codereturns200and persists 10; a headerdocamt: "9999"against a single"100"line is accepted; anarea/agent/projectcode that exists in no master list is stored verbatim;docnohas 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, neverf64. Meanwhilestatus,idtype,updatecount,taxtypeandlastmodifiedare integers, andcancelled,taxinclusive,isactiveandchangedare booleans. - List responses omit fields the detail returns — confirmed on three objects:
outstandingon documents,sdsbranch[]addresses on contacts, anduom/refcost/refprice/barcodeon items. Never treat a list row as a complete record. - Pagination is
?offset=N, max 50 rows per GET. End of pages is400 {"error":{"message":"No record found."}}, not an empty list, andcountis rows in this page, not the total. - No date-range filtering anywhere.
docdateis exact-match only;docdate>=,docdate_from/_toanda..ball fail. Worse, an unknown query parameter is silently ignored and returns the whole collection with a200— so every filtered read needs a test asserting the filter actually narrowed the result.fetch_*_recentmust 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.
IntegrationRetryAfterCacheis wired defensively (429 →TooManyRequestswith a 1-second floor when noRetry-Afteris present) and concurrency is held to 1–2. updatecountis mandatory on PUT but its value is ignored —0,1and99all returned200, while omitting it returns500 "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 forsupplierinvoice;paymentvouchercarries a realupdatecount, so re-probe per type. Also: inserts omitdtlkey(or send-1), lineseqincrements by 1000 (a Delphi convention), and achanged/dirtyflag must be set on headers, lines and master records.- 🚨 e-Invoice coupling (LHDN MyInvois).
submissiontypedefaults from the contact record, and a published stock-family document may be submitted to the Malaysian revenue authority on creation, after whichirbm_uuid/irbm_statusare set and the document is likely immutable. The decided safety posture is to sendsubmissiontype: 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, pluswhtax*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 runs6.2026.1092.899. Commercial; gates go-to-market, not code. SQL support. - Rate limits and
Retry-Aftersemantics — undocumented, nothing observed. SQL support. - Is
GET /versionauthenticated? 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. buyingratevssellingrateper direction (AP vs AR) — the sandbox has them equal on every currency, so probing gives no signal. Accountant.sdsbranch[].branchtypevalue set, and which branch is the billing address. Sandbox probe.exempted_taxrate/exempted_taxamtsemantics — present butnull/"0"on every ordinary document. Needs a document that uses one.itemtypevalue set. Sandbox probe.- The AR pair (
salesinvoice→customerinvoiceat onedockey) is assumed symmetric with AP and unverified. Probe before the AR publish task. - Document
statusinteger value set — only0has 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 #
-
Get OAuth Authorization URL
POST /api/v1/integrations/oauth/authorize- Parameters:
entity_id(required): UUID of the entityprovider:SHAREPOINTredirect_url(required): URL to redirect after OAuth flow
- Response: Returns authorization URL for SharePoint OAuth flow
- Parameters:
-
Fetch Available Sources
GET /api/v1/integrations/{integrationId}/trigger-sources- Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
- Response: List of available SharePoint sites
- Parameters:
-
Fetch Sub-sources (Document Libraries)
GET /api/v1/integrations/{integrationId}/trigger-sources/{sourceId}/sub-sources- Parameters:
integrationId(required): UUID of the integrationsourceId(required): SharePoint site IDentity_id(required): UUID of the entity
- Response: List of document libraries in the specified site
- Parameters:
-
Setup Integration
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "SHAREPOINT", "source_id": "sharepoint-site-id", "sub_source_id": "document-library-id", "directories": ["path/to/watch", "another/path"] } }
-
Google Drive (Trigger Integration) #
Google Drive integration allows you to monitor specific Google Drive folders for new documents.
Provider Code: GOOGLE_DRIVE
APIs Required for Activation #
-
Get OAuth Authorization URL
POST /api/v1/integrations/oauth/authorize- Parameters:
entity_id(required): UUID of the entityprovider:GOOGLE_DRIVEredirect_url(required): URL to redirect after OAuth flow
- Response: Returns authorization URL for Google Drive OAuth flow
- Parameters:
-
Fetch Available Sources
GET /api/v1/integrations/{integrationId}/trigger-sources- Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
- Response: List of available Google Drive folders
- Parameters:
-
Fetch Sub-sources (Sub-folders)
GET /api/v1/integrations/{integrationId}/trigger-sources/{sourceId}/sub-sources- Parameters:
integrationId(required): UUID of the integrationsourceId(required): Google Drive folder IDentity_id(required): UUID of the entity
- Response: List of sub-folders in the specified folder
- Parameters:
-
Setup Integration
PUT /api/v1/integrations/{integrationId}-
Parameters:
integrationId(required): UUID of the integrationentity_id(required): UUID of the entity
-
Request Body:
{ "status": "PENDING", "public_metadata": { "provider": "GOOGLE_DRIVE", "source_id": "google-drive-folder-id", "sub_source_id": "sub-folder-id", "directories": ["path/to/watch", "another/path"] } }
-
Complete API Documentation #
For detailed API specifications, request/response schemas, and additional endpoints, refer to the OpenAPI documentation.