quickbooks-client
Motoko bindings for a curated slice of the QuickBooks Online Accounting API
v3:
Customer, Invoice, Payment, Item, Account. Schemas were transcribed from
Intuit's official XSD. All 12 operations live in Apis/DefaultApi.mo:
saveCustomer/getCustomer, saveItem/getItem, saveAccount/getAccount,
saveInvoice/getInvoice/sendInvoice, savePayment/getPayment, and query_
(trailing underscore — query is a reserved word in Motoko).
Trigger phrases
Reach for this skill on any request mentioning: QuickBooks, QBO, invoice, "bill a customer", "send an invoice", record/receive a payment, customer, accounting, bookkeeping, chart of accounts, item/product/service, "sync to QuickBooks".
Not in this slice: Estimate, Bill, Vendor, PurchaseOrder, CreditMemo and the rest of the QBO entity set — the spec is hand-curated to Customer, Invoice, Payment, Item and Account. If a request needs one of the others, say so rather than mapping it onto Invoice; the spec has to be re-spun from Intuit's XSD first.
How QuickBooks authentication works (read before wiring)
QBO uses OAuth 2.0 Authorization Code — there is no static API key. Each
end-user authorises their QuickBooks company; the app exchanges the authorization
code for a short-lived Bearer access token (~1 hour) and passes it to the
client at call time. On expiry the API returns HTTP 401 — surface a
#Err("auth_expired") result and re-authenticate off-chain.
Two identifiers travel with every call:
realmId— the QuickBooks company id, obtained during the OAuth handshake. It is the first argument to every operation.minorversion— the API minor version (default"75"); pass""to omit and use the account default.
The token exchange is itself an on-chain outcall to Intuit's token endpoint and needs the app's Client Secret. Two hazards (identical to the googlemail connector):
is_replicated = ?falseon the exchange too — the token response is non-deterministic, so a replicated exchange duplicates across ~13 replicas and fails IC consensus.- The Client Secret leaks with exported source — Caffeine can export the app
to a
.zipor public GitHub repo, carrying the secret along. Scope it minimally and rotate if the source is shared.
Persist the per-user refresh/session token across upgrades (stable memory) so a
redeploy doesn't force re-authentication. OAuth scope: com.intuit.quickbooks.accounting.
Create vs. update vs. delete (important QBO semantics)
- Create and update BOTH use the
save<Entity>(POST) operation. It's an update when the body carries the entity'sIdand currentSyncToken(fetch them first viaget<Entity>); setsparse = ?truefor a partial update. WithoutId/SyncTokenit's a create. - Delete (transactions only —
Invoice,Payment): callsaveInvoice/savePaymentwithoperation = ?#deleteand a body carryingId+SyncToken. Theoperationargument is optional (?SaveInvoiceOperationParameter), not a bare variant. Passnullfor a normal create or update — the client then omits the query parameter entirely, which is what QBO wants; the spec says "Omit for create/update; 'delete' removes the invoice". Only?#deleteis routinely needed. Name-list entities (Customer,Item,Account) are not deletable — setActive = ?falseto deactivate instead (theirsave*ops take nooperationargument). - Responses are wrapped:
getCustomerreturnsCustomerResponsewith aCustomerfield (and atime); same shape per entity.query_returns aQueryResponsewhoseQueryResponsefield holds arrays per entity type. The operation isquery_, notquery. - Invoice lines and Payment lines are different types.
Invoice.Lineis[Line], whereDetailTypeis required.Payment.Lineis[PaymentLine]—{Amount, LinkedTxn, …}with noDetailType, which is what Intuit actually sends when a payment is applied to invoices. They were one shared type until 0.2.0, and that made every payment response fail to decode.
Frontend surfaces (two pages)
Like any per-user OAuth connector, an app needs two surfaces:
- An admin-gated Intuit app configuration page — the Client ID and Client Secret are canister-wide, set once by an admin; never expose the Client Secret to ordinary users.
- A per-user OAuth 2.0 handshake page — each user connects their own
QuickBooks company (yielding their
realmId+ token); re-prompt on HTTP 401.
Usage
import { saveInvoice; getInvoice; sendInvoice; saveCustomer }
"mo:quickbooks-client/Apis/DefaultApi";
import Invoice "mo:quickbooks-client/Models/Invoice";
import { defaultConfig } "mo:quickbooks-client/Config";
let cfg = {
defaultConfig with
auth = ?#bearer "<off-chain OAuth2 access token>";
is_replicated = ?false; // non-replicated: required for writes; reads too
};
let realmId = "<company id from OAuth handshake>";
// Build an invoice: one sales line, $100, item "1", billed to customer "58".
// Invoice has no required fields → init {} then record-update the optionals.
// NOTE: DetailType is REQUIRED on a Line, and its variants are lowercase.
let inv = {
Invoice.init {} with
CustomerRef = ?{ value = "58"; name = null };
Line = ?[ {
DetailType = #salesitemlinedetail; // required; lowercase variant
Amount = ?100.0;
Description = ?"Consulting";
SalesItemLineDetail = ?{ ItemRef = ?{ value = "1"; name = null };
Qty = ?1.0; UnitPrice = ?100.0; TaxCodeRef = null; ServiceDate = null };
Id = null; LineNum = null; LinkedTxn = null;
} ];
};
// saveInvoice(config, realmId, invoice, minorversion, operation)
// `operation` is optional: null omits it — the create/update path.
let created = await* saveInvoice(cfg, realmId, inv, "75", null);
// Email it to the customer (config, realmId, invoiceId, sendTo, minorversion):
// ignore await* sendInvoice(cfg, realmId, "<invoiceId>", "cust@example.com", "75");
Notes
- Use
is_replicated = ?falsefor writes (save*,sendInvoice, delete viaoperation = ?#delete). These outcalls are non-idempotent and QBO's response is non-deterministic; in replicated mode every replica issues the request — creating duplicate invoices/payments and failing IC consensus. Reads (get*,query_) also use?false(one node, ~13× cheaper). - Update needs a fresh
SyncToken— alwaysget<Entity>first, copy itsSyncTokeninto the body, thensave<Entity>. A stale token → HTTP 400 ("stale object"). minorversiondefaults to"75"; pass""to omit.- QBO returns errors as an
ErrorResponsewhose list field isFault.Error_— note the trailing underscore,Errorbeing reserved — so surfaceFault.Error_[0].Messageto the caller; never blind-retry a write. - Sandbox vs production: point the client host at
sandbox-quickbooks.api.intuit.comfor the Intuit sandbox company during development (edit the server inConfig), andquickbooks.api.intuit.comfor production.