1. Overview
The QuickBooks Webhook Integration is a real-time change notification system that receives inbound HTTP POST requests from QuickBooks Online (QBO) whenever an entity — Customer, Vendor, Account, Product/Item, Invoice, or Payment — is created, updated, or voided in QuickBooks. It automatically synchronises those changes into corresponding Salesforce custom objects.
The system is built in three layers:
-
REST Endpoint (
Webhook_QBChangeHandler) — receives the raw QBO webhook payload, parses it, and dispatches one batch job per entity type -
Batch Processors (six
BatchQBSF*Webhookclasses) — manage asynchronous, callout-enabled processing with a built-in concurrency guard -
Integration Helper (
QuickbooksIntegrationHelper) — contains per-entity business logic: Ignore-flag acknowledgement, live QB API queries, lookup resolution, and final DML viaUpsertCustomRecords
2. Component Architecture
QuickBooks Online
│ HTTP POST to /services/apexrest/getUpdatesQB/*
│ Header: RealmId
▼
┌───────────────────────────────────┐
│ Webhook_QBChangeHandler │ ← @RestResource, global
│ handleCallback() │
│ - Parses eventNotifications JSON │
│ - Groups QB IDs by entity type │
│ - Dispatches batch per entity │
└────────────┬──────────────────────┘
│ Database.executeBatch(batchSize from Custom Setting)
│
┌─────────┴─────────────────────────────────────────────────────┐
│ Entity-Specific Batch Classes |
│ │
│ BatchQBSFCustomerWebhook BatchQBSFVendorWebhook │
│ BatchQBSFAccountWebhook BatchQBSFProductWebhook │
│ BatchQBSFInvoiceWebhook BatchQBSFPaymentWebhook │
│ BatchQBSFSalesReceiptWebhook BatchQBSFPaymentMethodWebhook │
│ start() → Concurrency Guard (AsyncApexJob check) │
│ execute() → delegate to QuickBooksIntegrationHelper │
│ finish() → debug log │
└────────────┬──────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ QuickbooksIntegrationHelper │ ← public, instance-based
│ │
│ handleQBCustomer() │
│ handleQBVendor() │
│ handleQBAccount() │
│ handleQBProduct() │
│ handleQBInvoice() │
│ handleQBPayment() │
│ handleQBSalesReceipt() │
│ handleQBPaymentMethod() │
└────────────┬──────────────────────┘
│
┌───────┴──────────┐
│ │
▼ ▼
WS_Quickbooks UpsertCustomRecords
(QB API callout) (without sharing DML)
3. Inbound Webhook Payload Structure
QuickBooks Online sends the following JSON payload on entity change:
{
"eventNotifications": [
{
"realmId": "1234567890",
"dataChangeEvent": {
"entities": [
{
"name": "Customer",
"id": "123",
"operation": "Create",
"lastUpdated": "2025-01-15T12:00:00.000Z"
},
{
"name": "Invoice",
"id": "456",
"operation": "Update",
"lastUpdated": "2025-01-15T12:01:00.000Z"
}
]
}
}
]
}
Important notes:
-
Only the first element of
eventNotifications(lstEvents[0]) is processed. QBO typically sends one notification per realm per webhook delivery. -
The
RealmIdis read from the HTTP request header (req.headers.get('RealmId')), not from the JSON body. -
Only operations
create,update, andvoidare processed.deleteis intentionally ignored.
4. Class Reference
4.1 Webhook_QBChangeHandler
File: force-app/main/default/classes/Webhook_QBChangeHandler.cls Access Modifier: global with sharing REST Endpoint: @RestResource(urlMapping='/getUpdatesQB/*') HTTP Method: @HttpPost
Method: handleCallback
@HttpPost global static void handleCallback()
Responsibilities:
-
Reads the raw request body (
RestContext.request.requestbody) and theRealmIdheader. -
Parses
eventNotifications[0].dataChangeEvent.entitiesfrom the JSON payload. -
Filters to only
create,update,voidoperations. -
Groups QB entity IDs into a
Map<String, List<String>>keyed by lowercase entity name. -
Reads batch size from
cm_finance__Quickbooks_Common_Settings__cinstanceBatchSize(default:50). -
Dispatches a
Database.executeBatch()call for each entity type present in the payload. -
Always responds HTTP
200via thefinallyblock — regardless of parse errors or exceptions.
Supported Entity Name Keys (lowercase):
|
QB Entity Name (lowercase) |
Batch Class Dispatched |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Design note: QB sends
itemas the entity name for Products/Services. The handler mapsitem→BatchQBSFProductWebhook. Any entity name not in this set is silently discarded.
Always HTTP 200: The finally block unconditionally sets res.statusCode = 200. This is required by QBO's webhook contract — QBO will retry delivery if it receives a non-200 response. Returning 200 immediately acknowledges receipt and prevents retries, with all actual processing happening asynchronously via batch.
Parse error behaviour: Any exception during JSON parsing or batch dispatch is caught and debug-logged. The finally block still fires, so QBO always gets its 200 ACK.
4.2 Batch Webhook Classes
All six batch classes follow an identical structural pattern. They differ only in the entity type they handle.
|
Class |
Handles |
Delegates to |
|---|---|---|
|
|
Customer |
|
|
|
Account (Chart of Accounts) |
|
|
|
Product/Item |
|
|
|
Invoice |
|
|
|
Payment |
|
|
|
Vendor |
|
|
|
Sales Receipt |
|
|
|
Payment Method |
|
Common class structure:
Implements: Database.Batchable<String>, Database.AllowsCallouts
Access: public with sharing
Constructor
Each class has two constructors:
// Default (empty — used for programmatic instantiation)
public BatchQBSFXxxWebhook()
// Primary (used by Webhook_QBChangeHandler)
public BatchQBSFXxxWebhook(List<String> lstQBIds, String realmId)
|
Parameter |
Type |
Description |
|---|---|---|
|
|
|
QB entity IDs received in the webhook payload for this entity type |
|
|
|
QB Realm/Company ID from the request |
start(Database.BatchableContext BC) — Concurrency Guard
This is the most important method in every batch class. Before returning the record iterable, it checks whether another instance of the same batch class is already running:
String sQuery = 'SELECT Id FROM AsyncApexJob
WHERE status = :sStatus
AND ApexClass.Name = :sApexClassName
AND Id != :thisJobId';
|
Condition |
Result |
|---|---|
|
Another instance of this batch is already |
Returns empty |
|
No other instance running |
Returns |
Why this matters: QuickBooks can send multiple webhook deliveries in rapid succession for the same entity type (e.g., bulk imports). This guard ensures that at most one batch of each entity type runs at a time, preventing duplicate API callouts and DML conflicts.
Note:
BatchQBSFAccountWebhookhas a minor omission — it does not includesStatusandsApexClassNameas bind variables in the query's parameter map (passes onlythisJobId). The query still executes correctly because the other bind variables (sStatus,sApexClassName) resolve from the surrounding method scope, but it is inconsistent with the other five batch classes.
execute(Database.BatchableContext BC, List<String> lstRecordQBIds)
Instantiates QuickBooksIntegrationHelper, sets its lstRecordQBIds and realmId properties, then calls the appropriate handle*() method:
QuickBooksIntegrationHelper oHelper = new QuickBooksIntegrationHelper();
oHelper.lstRecordQBIds = lstRecordQBIds;
oHelper.realmId = this.realmId;
oHelper.handleQBCustomer(); // (or the relevant entity method)
finish(Database.BatchableContext BC)
Writes a debug log only. No post-processing logic.
4.3 QuickbooksIntegrationHelper
File: force-app/main/default/classes/QuickbooksIntegrationHelper.cls Access Modifier: public with sharing
The core processing class. Instantiated fresh per batch execute() call. State is set via public instance properties before calling any handle method.
Instance Properties
|
Property |
Type |
Set By |
|---|---|---|
|
|
|
Batch |
|
|
|
Batch |
Static Properties
|
Property |
Source |
|---|---|
|
|
|
|
|
|
Universal Processing Pattern
All six handler methods share the same core pattern. Understanding this pattern once explains all of them:
Step 1: Resolve QB Company
→ Query cm_finance__Quickbooks_Company__c WHERE Company_ID__c = realmId AND Active__c = true
→ If not found: create Failed records for all IDs and exit
Step 2: Ignore-Flag Acknowledgement (automation echo suppression)
→ Query SF for existing records WHERE QB_Id IN lstRecordQBIds
AND Ignore_Webhook__c = true AND QB_Company = sCompanyRecId
→ For each matched record:
- Remove its QB ID from lstRecordQBIds (so it won't be re-fetched from QBO)
- Clear Ignore_Webhook__c = false
- Add to the "success" list directly
Step 3: Live QB API Callout (only for remaining IDs)
→ Build QB SQL query: select * from <Entity> WHERE Id IN ('id1','id2',...)
→ Call WS_Quickbooks.query<Entity>()
→ On API success: parse response, resolve lookups, set Composite_Unique_Key__c, set status
→ On API failure: create Failed records for each remaining ID
Step 4: DML via UpsertCustomRecords
→ UpsertCustomRecords.upsert<Entity>Records(allRecords)
The Ignore-Flag pattern is the key anti-loop mechanism. When Salesforce creates or updates a QB record (via the Automation Service or batch sync), it sets Ignore_Webhook__c = true. When QBO echoes that change back as a webhook, Step 2 identifies and acknowledges those records without making a redundant API callout — it simply clears the flag. Only genuinely external QBO changes (QB IDs not matched in Step 2) trigger a live API fetch.
Method: handleQBCustomer
QB API Query: select * from Customer WHERE Id IN ('...') SF Target Object: cm_finance__Quickbooks_Customer__c Composite_Unique_Key format: {QB_Id}::Customer::{RealmId} Status on success: Awaiting Salesforce Sync
Additional Logic — Parent Customer Resolution:
After parsing the QB API response, the handler resolves sub-customer parent relationships:
-
Collects all
cm_finance__Parent_QB_Customer_QB_Id__cvalues from the parsed customer records. -
Queries
cm_finance__Quickbooks_Customer__cin Salesforce by those QB IDs to find the corresponding SF record IDs. -
Sets
cm_finance__Parent_Quickbooks_Customer__clookup field on each sub-customer record.
This ensures sub-customers are linked to their parent customer's SF record rather than just holding the raw QB ID.
Failure Records (per ID):
|
Scenario |
Status |
Status_Description__c |
|---|---|---|
|
QB Company not found |
|
|
|
|
|
|
|
|
|
API error message from QBO |
|
Unhandled exception |
|
Exception message + line number |
All failure records are also upserted via UpsertCustomRecords.upsertQBCustomerRecords().
Method: handleQBVendor
QB API Query: select * from Vendor WHERE Id IN ('...') SF Target Object: cm_finance__Quickbooks_Vendor__c Composite_Unique_Key format: {QB_Id}::Vendor::{RealmId} Status on success: Awaiting Salesforce Sync
Follows the universal pattern with no additional lookup resolution. No parent-child relationship handling (vendors are always top-level).
Method: handleQBAccount
QB API Query: select * from Account WHERE Id IN ('...') SF Target Object: cm_finance__Quickbooks_Account__c Composite_Unique_Key format: {QB_Id}::Account::{RealmId} Status on success: Successfully Synced (differs from other entities — Accounts sync directly with no further SF-side processing required)
Follows the universal pattern. No additional lookup resolution.
Method: handleQBPaymentMethod
QB API Query: select * from PaymentMethod WHERE Id IN ('...') SF Target Object: cm_finance__Payment_Method__c Composite_Unique_Key format: {QB_Id}::PaymentMethod::{RealmId} Status on success: Successfully Synced (differs from other entities — Accounts sync directly with no further SF-side processing required)
Follows the universal pattern. No additional lookup resolution.
Method: handleQBProduct
QB API Query: select * from Item WHERE Id IN ('...') SF Target Object: cm_finance__Quickbooks_Product__c Composite_Unique_Key format: {QB_Id}::Product::{RealmId} Status on success: Awaiting Salesforce Sync
Additional Logic — Account Lookup Resolution:
After parsing the QB API response, the handler resolves the three financial account references for each product:
-
Collects QB IDs from
cm_finance__QB_Income_Account_QB_Id__c,cm_finance__QB_Expense_Account_QB_Id__c, andcm_finance__QB_Asset_Account_QB_Id__c. -
Queries
cm_finance__Quickbooks_Account__cto resolve QB IDs → SF record IDs. -
Populates the three lookup fields:
cm_finance__Income_Account__c,cm_finance__Expense_Account__c,cm_finance__Asset_Account__c.
Additional Logic — Deduplication Before Upsert:
Before calling UpsertCustomRecords, the handler deduplicates lstAllProducts by Composite_Unique_Key__c:
Map<String, cm_finance__Quickbooks_Product__c> dedupMap = new Map<String, ...>();
for(cm_finance__Quickbooks_Product__c oProd : lstAllProducts){
if(!dedupMap.containsKey(oProd.Composite_Unique_Key__c) || oProd.Id != null){
dedupMap.put(oProd.Composite_Unique_Key__c, oProd);
}
}
UpsertCustomRecords.upsertQBProductRecords(dedupMap.values());
A record with an Id set (existing SF record from Step 2) always wins over a newly constructed record for the same key. This prevents upsert duplicate errors when both an Ignore_Webhook__c = true match and a fresh API parse produce a record for the same QB ID.
Method: handleQBInvoice
QB API Query: select * from Invoice WHERE Id IN ('...') SF Target Objects: cm_finance__Quickbooks_Invoice__c, cm_finance__Quickbooks_Invoice_Item__c Composite_Unique_Key format (Invoice): {QB_Id}::Invoice::{RealmId} Status on success: Invoice PDF Pending (if GenerateInvoicePDF setting = true) or Awaiting Salesforce Sync
This is the most complex handler. After the universal Steps 1–3, it performs the following additional processing:
Step 3a — Four Lookup Resolutions:
|
Lookup |
Collected From |
Queries |
Maps |
|---|---|---|---|
|
QB Customer → SF Customer |
|
|
QB ID → SF record ID |
|
QB Product → SF Product |
|
|
QB ID → SF record ID |
|
QB Class → SF Class |
|
|
QB ID → SF record ID |
|
QB Custom Field Def → SF Custom Field |
|
|
Definition ID → SF record ID |
Step 3b — Custom Field Decoding:
Custom field values are temporarily stored in cm_finance__Custom_Field_1/2/3__c using the format DefinitionId::FieldValue. The handler splits each value, resolves the SF Custom Field record ID, and writes the actual value:
Before: cm_finance__Custom_Field_1__c = "abcd1234::Project Alpha"
After: cm_finance__Custom_Field_1__c = "Project Alpha"
cm_finance__Quickbooks_Custom_Field_1__c = <SF record Id of Custom Field>
Step 4a — Invoice Upsert:
UpsertCustomRecords.upsertQBInvoiceRecords(lstAllSuccessInvoice);
Step 4b — Line Item Reconciliation (post-invoice-upsert):
After invoices are upserted (and SF IDs are assigned), the handler performs full line-item reconciliation:
1. Fetch all existing cm_finance__Quickbooks_Invoice_Item__c
WHERE Invoice__c IN (upserted invoice SF IDs)
2. Build a Map<Composite_Unique_Key__c, Invoice_Item__c> of existing items
- Items without a Composite_Unique_Key__c → flag for deletion/archival
3. For each new line item from QBO:
a. Set Invoice__c = the parent invoice's new SF ID
b. Resolve QB Product ID → SF Product ID (mapQBLineItem)
c. Resolve QB Class ID → SF Class ID (mapQBClass)
d. If Composite_Unique_Key__c matches an existing item:
carry the existing item's Id (update path)
e. If no match: insert as new line item
4. Orphaned existing line items (in SF but not in new QBO data):
- If CMQBConnectorUtility.deleteExtraLineItemsSetting() = true:
→ data.remove() (hard delete)
- If false:
→ Set Archived__c = true (soft delete / audit trail)
5. UpsertCustomRecords.upsertQBInvoiceItemRecords(lstAllLineItems)
This reconciliation ensures that line item additions, modifications, and removals from QBO are all accurately reflected in Salesforce on every invoice webhook event.
Method: handleQBSalesReceipt
QB API Query: select * from SalesReceipt WHERE Id IN ('...') SF Target Objects: cm_finance__Quickbooks_Sales_Receipt__c, cm_finance__Quickbooks_Sales_Receipt_Item__c Composite_Unique_Key format (Sales Receipt): {QB_Id}::SalesReceipt::{RealmId} Status on success: Sales Receipt PDF Pending (if the sales-receipt PDF setting is enabled) or Awaiting Salesforce Sync
This handler follows the universal Steps 1–3 and then applies the same lookup, custom-field, parent-record upsert, and line-item reconciliation pattern used by handleQBInvoice.
Step 3a — Six Lookup Resolutions:
|
Lookup |
Collected From |
Queries |
Maps |
|---|---|---|---|
|
QB Customer → SF Customer |
|
|
QB ID → SF record ID |
|
QB Product → SF Product |
|
|
QB ID → SF record ID |
|
QB Class → SF Class |
|
|
QB ID → SF record ID |
|
QB Custom Field Def → SF Custom Field |
|
|
Definition ID → SF record ID |
|
QB Account → SF Account |
|
|
QB ID → SF record ID |
|
QB Payment Method → SF Payment Method |
|
|
QB ID → SF record ID |
Step 3b — Custom Field Decoding:
Custom-field values are temporarily stored in cm_finance__Custom_Field_1/2/3__c using the format DefinitionId::FieldValue. The handler splits each value, resolves the Salesforce Custom Field record ID, and writes the actual value:
Before: cm_finance__Custom_Field_1__c = "abcd1234::Project Alpha"
After: cm_finance__Custom_Field_1__c = "Project Alpha"
cm_finance__Quickbooks_Custom_Field_1__c = <SF record Id of Custom Field>
Step 4a — Sales Receipt Upsert:
UpsertCustomRecords.upsertQBSalesReceiptRecords(lstAllSuccessSalesReceipt);
Step 4b — Line Item Reconciliation (post-sales-receipt-upsert):
After sales receipts are upserted and Salesforce IDs are assigned, the handler reconciles the related line items:
1. Fetch all existing cm_finance__Quickbooks_Sales_Receipt_Item__c
WHERE Sales_Receipt__c IN (upserted sales-receipt SF IDs)
2. Build a Map<Composite_Unique_Key__c, Sales_Receipt_Item__c> of existing items
- Items without a Composite_Unique_Key__c → flag for deletion/archival
3. For each new line item from QBO:
a. Set Sales_Receipt__c = the parent sales receipt's new SF ID
b. Resolve QB Product ID → SF Product ID (mapQBLineItem)
c. Resolve QB Class ID → SF Class ID (mapQBClass)
d. If Composite_Unique_Key__c matches an existing item:
carry the existing item's Id (update path)
e. If no match: insert as a new line item
4. Orphaned existing line items (in SF but not in new QBO data):
- If CMQBConnectorUtility.deleteExtraLineItemsSetting() = true:
→ data.remove() (hard delete)
- If false:
→ Set Archived__c = true (soft delete / audit trail)
5. UpsertCustomRecords.upsertQBSalesReceiptItemRecords(lstAllLineItems)
This reconciliation ensures that sales-receipt line-item additions, modifications, and removals from QBO are accurately reflected in Salesforce on every sales-receipt webhook event.
Method: handleQBPayment
QB API Query: select * from Payment WHERE Id IN ('...') SF Target Object: cm_finance__Payment_Transaction__c Composite_Unique_Key format: {QB_Id}::Payment::{RealmId} (no entity type token in the key) Status on success: Payment Receipt PDF Pending (if getPaymentReceipt = true) or Successfully Synced
Additional fields set on payment records:
|
Field |
Value |
|---|---|
|
|
Set equal to |
|
|
|
4.4 UpsertCustomRecords
File: force-app/main/default/classes/UpsertCustomRecords.cls Access Modifier: public without sharing
Critical design note: This class is
without sharing. All webhook sync DML bypasses record-level sharing rules. This is intentional — webhook processing runs as a system-level operation and must succeed regardless of the running user's record access.
Provides a single static upsert method per entity. All methods use Composite_Unique_Key__c as the external ID upsert key, except credentials which use Name.
|
Method |
Target Object |
Upsert Key |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
— (direct update, FLS = |
All upsert methods delegate to Data.upsurt() with FLS enforcement inherited from CMQBConnectorUtility.enforceFLS.
5. Composite Unique Key Reference
The Composite_Unique_Key__c field is the external ID used for all upsert operations. It guarantees idempotency — running the same webhook notification twice produces the same result, not duplicates.
|
Entity |
Key Format |
Example |
|---|---|---|
|
Customer |
|
|
|
Vendor |
|
|
|
Account |
|
|
|
Product |
|
|
|
Invoice |
|
|
|
Invoice Item |
Set by |
— |
|
Payment |
|
|
|
Sales Receipt |
|
|
|
Sales Receipt Item |
Set by |
|
|
Payment Method |
|
|
Note on Payment key: The Payment key omits the entity type token (
::Payment::is still present as a literal separator, but the pattern isQB_Id::Payment::RealmId, notQB_Id::RealmId). This is consistent with howQueueableAutomateQBServicesets payment keys and distinguishes payments from invoices at the same QB ID.
6. End-to-End Flow Diagrams
6.1 Normal Webhook (External QBO Change)
QuickBooks Online
│ POST /services/apexrest/getUpdatesQB/*
│ Header: RealmId: 9876543210
│ Body: { "eventNotifications": [{ "dataChangeEvent": { "entities": [
│ { "name": "Customer", "id": "123", "operation": "Update" },
│ { "name": "Invoice", "id": "456", "operation": "Create" }
│ ]}}]}
▼
Webhook_QBChangeHandler.handleCallback()
│
├── Parse JSON → mapEntitiesData = { "customer": ["123"], "invoice": ["456"] }
├── Read BatchSize from Custom Setting (default: 50)
├── Database.executeBatch(BatchQBSFCustomerWebhook(["123"], "9876543210"), 50)
├── Database.executeBatch(BatchQBSFInvoiceWebhook(["456"], "9876543210"), 50)
└── res.statusCode = 200 [always, in finally block]
[Asynchronous - separate transactions]
BatchQBSFCustomerWebhook.start()
│
├── Query AsyncApexJob: is another BatchQBSFCustomerWebhook running?
│ → No → return ["123"]
│ → Yes → return [] (exits)
│
BatchQBSFCustomerWebhook.execute(["123"])
│
▼
QuickBooksIntegrationHelper.handleQBCustomer()
│
├── Step 1: Resolve company by realmId "9876543210" → SF Company record found
├── Step 2: Query: Quickbooks_Customer__c WHERE QB_Id IN ["123"] AND Ignore_Webhook = true
│ → No match (this was an external QBO change, not SF-originated)
├── Step 3: Live API callout: select * from Customer WHERE Id IN ('123')
│ → QB returns full Customer data
│ → Resolve parent customer QB ID → SF record ID (if sub-customer)
│ → Set Composite_Unique_Key__c = "123::Customer::9876543210"
│ → Set Status = "Awaiting Salesforce Sync"
└── Step 4: UpsertCustomRecords.upsertQBCustomerRecords([customerRecord])
→ Data.upsurt() on Composite_Unique_Key__c
6.2 Echo Suppression (Salesforce-Originated Change)
Salesforce Flow triggers → InvocableAutomateQBService
│
├── Creates cm_finance__Quickbooks_Customer__c with:
│ Quickbooks_Id__c = "123"
│ Ignore_Webhook__c = true ← FLAGGED
│ Process_via_Batch__c = true
│
│ [Batch sync runs, pushes record to QBO]
│
↓
QuickBooks Online receives the change and echoes it back as a webhook
Webhook_QBChangeHandler receives: entity Customer id="123" operation="Update"
│
▼
QuickBooksIntegrationHelper.handleQBCustomer()
│
├── Step 2: Query: QB_Id IN ["123"] AND Ignore_Webhook__c = true
│ → MATCH FOUND (the SF-created record)
│ → Remove "123" from lstRecordQBIds
│ → Set Ignore_Webhook__c = false on that record
│ → Add to lstAllCustomers
│
├── lstRecordQBIds is now EMPTY → skip Step 3 (no API callout)
│
└── Step 4: UpsertCustomRecords.upsertQBCustomerRecords([customerRecord])
→ Only updates Ignore_Webhook__c = false
→ No redundant API callout, no duplicate record
6.3 Invoice Webhook with Line Item Reconciliation
QBO sends: Invoice id="456" operation="Update"
│
▼
QuickBooksIntegrationHelper.handleQBInvoice()
│
├── Resolve Company
├── Ignore-flag check → no match (external update)
├── QB API callout: select * from Invoice WHERE Id IN ('456')
│ → Returns: Invoice with 3 line items (was 4, one was deleted in QB)
│
├── Resolve 4 lookups:
│ Customer QB ID → SF Customer record
│ Product QB IDs → SF Product records (for each line item)
│ Class QB IDs → SF Class records
│ Custom Field Def IDs → SF Custom Field records
│
├── Decode custom fields: "defId::value" → actual value + SF lookup
│
├── UpsertCustomRecords.upsertQBInvoiceRecords([invoice])
│ → Upserts invoice header (create or update in SF)
│
├── Fetch existing Invoice Items for this invoice from SF (4 items)
│ → Build Map<Composite_Key, Item> of existing items
│
├── For each of the 3 new line items from QBO:
│ → Composite key match found for items 1, 2, 3 → carry SF Id (update)
│ → Resolve Product lookup, Class lookup
│
├── Orphaned item (old item 4, now gone from QBO):
│ → Check deleteExtraLineItemsSetting()
│ true → data.remove() (hard delete)
│ false → Set Archived__c = true (soft archive)
│
└── UpsertCustomRecords.upsertQBInvoiceItemRecords([3 line items])
7. Record Status Lifecycle (Webhook Path)
|
Entity |
Status After Webhook Processing |
|---|---|
|
Customer |
|
|
Vendor |
|
|
Account |
|
|
Product |
|
|
Invoice |
|
|
Sales Receipt |
|
|
Payment |
|
|
Payment Method |
|
Ignore_Webhook__c lifecycle:
|
Trigger |
Value |
|---|---|
|
Record created by SF Automation Service (Invocable) |
|
|
Record created by SF Batch Sync |
|
|
Webhook receives echo of SF-originated change |
Cleared to |
|
Webhook processes an external QBO change |
Not set on new record (default |
8. Error Handling Strategy
All handlers implement a three-tier error strategy:
Tier 1 — Company Not Found
Condition: cm_finance__Quickbooks_Company__c not found for the realmId
Action: Create Failed records for ALL QB IDs in lstRecordQBIds
(without even attempting an API callout)
Upsert via UpsertCustomRecords
Tier 2 — API Response Failure
Condition: oAPIResponse is null, OR isSuccess = false
Action: Create Failed records for all QB IDs remaining in lstRecordQBIds
(the Ignore-flag-matched IDs are still upserted normally)
Error message from oAPIResponse.errorMessage (if available) stored
in Status_Description__c
Tier 3 — Unhandled Exception (outer catch)
Condition: Any unexpected exception
Action: Create Failed records for all KB IDs in the original lstRecordQBIds
(using this.realmId since company lookup may have failed)
Attempt to upsert via UpsertCustomRecords (wrapped in try/catch)
Debug log the exception with line number
Failed Record Fields:
|
Field |
Value |
|---|---|
|
|
The QB ID being processed |
|
|
|
|
|
|
|
|
|
|
|
Full error message + class name + line number |
|
|
|
9. Configuration Reference
cm_finance__Quickbooks_Common_Settings__c (Hierarchy Custom Setting)
|
Instance Name |
Default |
Effect |
|---|---|---|
|
|
|
Batch size passed to |
|
|
|
Invoice webhook sets status to |
|
|
|
User types that bypass FLS enforcement in all webhook classes |
cm_finance__Quickbooks_Credentials__c (Hierarchy Custom Setting)
|
Field |
Purpose |
|---|---|
|
|
The registered public URL pointing to |
|
|
Enables or disables sending webhook-triggered data back to QB (used by AdminSetupController) |
deleteExtraLineItemsSetting() (CMQBConnectorUtility)
Controls what happens to invoice line items in Salesforce that no longer exist in QBO after an invoice webhook update:
|
Value |
Behaviour |
|---|---|
|
|
Hard delete ( |
|
|
Soft archive ( |
10. Concurrency and Idempotency Design
Concurrency Guard (per batch class)
The start() method of each batch class queries AsyncApexJob for other running instances of the same class. If a concurrent run is detected, the batch returns an empty iterable, causing it to exit without processing. This prevents:
-
Multiple simultaneous API callouts to QBO for the same entity type
-
DML lock contention on
Composite_Unique_Key__c-based upserts -
Duplicate or conflicting writes from back-to-back webhook deliveries
Idempotency (via Composite_Unique_Key__c)
Every handler builds a Composite_Unique_Key__c before upserting. Re-processing the same webhook notification for the same entity always produces an upsert (not a duplicate insert). The operation is safe to retry.
Echo Suppression (via Ignore_Webhook__c)
Records created or updated by Salesforce-side automation are flagged with Ignore_Webhook__c = true. When QBO echoes that change back as a webhook, the handler identifies these records in Step 2 and skips the live API callout — only clearing the flag. Without this mechanism, every SF-originated QB write would trigger a full round-trip API fetch, doubling the callout count.
11. Key Design Decisions
|
Decision |
Rationale |
|---|---|
|
Always return HTTP 200 from endpoint |
QBO retries delivery on non-200 responses. Returning 200 immediately acknowledges receipt and prevents retries; async batch handles all actual work. |
|
|
QB IDs are strings, not SF records. Using a String iterable avoids a preliminary SOQL query just to get an iterable and keeps the batch construction simple. |
|
Concurrency guard in |
Prevents the batch from being started in a way that would process records while another instance is mid-flight. Checking in |
|
|
Webhook sync is a system-level operation triggered by QBO. Record-level sharing restrictions should not prevent an entity created in QB from being synced into SF. |
|
Deduplication only on Product, not other entities |
Products are the only entity where both an Ignore-flag match (Step 2) and a fresh API response (Step 3) could generate records with the same |
|
Line item reconciliation uses Archived flag option |
Preserves audit history — deleted QB line items are not permanently removed from SF unless explicitly configured. Admins can choose hard delete vs. soft archive via |
|
Account status is |
Chart of Accounts records do not go through further SF-side processing (no PDF generation, no parent sync). They are considered fully synced as soon as the webhook upsert completes. |
|
Payment Composite Key has no entity type token in the positional sense, but is still distinct |
|