Vital Guides

WOOCOMMERCE TO KEAP TO QUICKBOOKS: ONE SOURCE OF TRUTH, NOT TWO WRITERS

Two plugins each wrote their own copy of every sale, and the copies drifted. The fix is not a third plugin. It is a direction of flow: the store feeds the CRM, the CRM feeds the books, and a sale is not a sale until its payment is recorded. One writer per fact.

Updated September 2, 2026· Measured on live Keap and QuickBooks companies, August 2026· WooCommerce, Keap, QuickBooks· Intermediate

TWO PLUGINS, TWO WRITERS, TWO ANSWERS

Your bookkeeper says the store did one number last month. Keap says a smaller one. WooCommerce says something else again, and every one of the three is right about what it counted.

We were hired by a client running WooCommerce with one plugin syncing to Keap and another syncing to QuickBooks. Both plugins worked. That was the problem. Each took a WooCommerce order and wrote its own copy of it: one as a Keap order on the contact, one as a QuickBooks invoice on a customer. Two writers, two copies, two chances to disagree, and nothing anywhere that said which copy was the real one.

The disagreements were ordinary. A refund processed in the store reached one system and not the other. A subscription renewal that Keap charged on its own never passed through WooCommerce, so the books never heard about it. A product renamed in the store matched Keap by its stock keeping unit (SKU) and QuickBooks by its name, and the two had drifted apart long ago.

The worse failure was quieter. The QuickBooks invoice had a customer on it, but that customer was built by the store plugin from a billing address. It had never met the Keap contact. The books knew a person spent money, the CRM knew a person existed, and the join between those two facts lived in nobody's system. Ask what one contact has paid you in total and you got two answers, or none.

THE STORE FEEDS THE CRM, THE CRM FEEDS THE BOOKS

The rule we settled on, and now enforce in code so nobody can talk themselves out of it on a Friday, is a direction. WooCommerce syncs into the CRM. QuickBooks Online (QBO) is written from the CRM. The store never writes the books.

Why the CRM in the middle? Because the CRM is where the customer lives. A sale that becomes a Keap order first is attached to the contact who bought it, beside every other order they placed. The QuickBooks invoice written from that order then carries a customer who is the same person as the contact, rather than a lookalike assembled from a shipping form.

A store that writes the books itself is a second writer whose sales never met the customer record. That is the two plugin shape in one sentence, and why it cannot be fixed by configuring the plugins better. They are both correct. They are just both writing.

One writer per fact. The store owns the cart. The CRM owns the customer and the sale. The books own the ledger, written from the sale. When two systems can both write the same fact they will, and then they will disagree, and then somebody will spend a Saturday with two exports and a highlighter.

Everything downstream now reads the Keap order, so the Keap order has to be right. It is less simple than it looks, in three places.

A KEAP ORDER IS A RECEIVABLE UNTIL YOU PAY IT

Creating an order in Keap does not record money. POST /v1/orders lands the order at status: DRAFT with total_paid: 0. It is an invoice somebody has not paid, which is a receivable, not a sale. Revenue takes a second call.

We proved the chain end to end on a Keap sandbox, order 43126.

Keap v1measured responses, sandbox order 43126
// 1. Create. This is a receivable.
POST /crm/rest/v1/orders
{ "contact_id": <contact id>, "order_items": [{ "product_id": <product id>, "quantity": 1 }] }

// came back:
{ "status": "DRAFT", "total_paid": 0, "total_due": 49 }

// 2. Record the money. THIS is the sale.
POST /crm/rest/v1/orders/43126/payments
{ "payment_amount": 49, "charge_now": false }

// answered: Successful Transaction

// 3. Read back:
{ "status": "PAID", "total_paid": 49, "total_due": 49 }   // yes, still 49

Three things in that exchange cost an afternoon each if you meet them cold.

The payment field is payment_amount, not amount. Send amount and Keap answers {"message":"Payment Amount is invalid"}. That error names the field it does not want in a way that reads as a value problem. You will check the number, the currency, and whether cents were meant, and the field was simply misspelled.

Set charge_now: false when the money was taken somewhere else, which for a WooCommerce sale is always. Leave it out and Keap tries to charge a card on file for money the customer has already paid.

total_due does not fall. The status flips, total_paid rises, and total_due stays where it was. We saw it on two sandboxes, one at 49 and one at 3497 paid against 3497 with 3497 still due. It is not an outstanding balance.

Anything that creates orders without payments builds a record that looks like a sale, counts as a sale on any report that sums total, and represents money that never arrived.

THE AUTOMATION THAT NEVER FIRED

That second call is load bearing for more than revenue. It is what runs the customer's automation.

Keap has a native Product Purchase goal. Point it at a product and a purchase of that product starts the sequence. We wanted to know exactly what "purchase" means to it, so we measured it on the sandbox on August 17, 2026 with a campaign we configured by hand: a Product Purchase goal on a product called Pants, applying a tag called pants purchased.

Order 43128 was created by API and left at DRAFT. Twelve seconds later the contact carried no tag. Then the payment call landed, and roughly twenty seconds after that the contact carried pants purchased. No goal call anywhere in our code. Keap saw the payment and ran its own logic.

So a goal set to fire on a paid invoice genuinely cannot be tripped by a receivable. That is the safety net, and it is also why we prefer the Product Purchase goal over the purchase tag most store plugins apply:

The corollary is the sharp one. An order left DRAFT means the customer's sequence never runs, and nobody finds out until somebody misses an email they were expecting. There is no error, because nothing failed. And goal processing is not instant, twenty seconds on a quiet sandbox, so the payment response is never confirmation the automation ran. The tag on the contact is.

THE SKU YOU CANNOT COUNT ON

Creating that Keap order needs a Keap product id on every line, and the obvious way to find one is the SKU the store already has. That works on some accounts and not others, and you cannot tell which from the outside.

We measured two catalogues. A live consulting account: 49 of 53 products carry a SKU, with no duplicates, so SKU is a usable key there. A sandbox app: 1 of 5. Anything keyed on SKU needs an answer for the products that have none, and matching on name instead is not it. One of the four SKU less products on that live account is called "Services".

The incumbent Keap plugin already knows this: its product tab's Keap product dropdown carries a placeholder telling you to leave it empty to fall back to a search by SKU. So the association is explicit first, SKU second. A store product with a chosen Keap product uses it, and only a product with none falls back to the lookup. That is the right rule and we kept it. A line that resolves neither way is refused and reported, never guessed.

Subscriptions are stranger. A plan is nested inside a product, at /v1/products/{id}/subscriptions/{planId}, and there is no flat plan catalogue. On the live account, 17 of 53 products carried plans. For a subscription product the store price is not authoritative at all: the incumbent overwrites the WooCommerce price with the plan's price on save, which is the correct instinct even if it surprises whoever edits the product next.

Then the two product endpoints describe the same plan differently, and each withholds what the other supplies.

Keap v1measured, all four sandbox plans
// GET /crm/rest/v1/products   (the list; each product carries its plans)
{ "id": <plan id>, "cycle": 2 }                        // numeric cycle, NO cycle_type at all

// GET /crm/rest/v1/products/{id}   (the single read, same plan)
{ "id": <plan id>, "cycle": 0, "cycle_type": "MONTH" }  // the word appears, cycle is 0 EVERY time

// Empirical mapping, from matching the same plans across both:
//   1 = YEAR, 2 = MONTH
//   WEEK and DAY are 3 and 6 in Keap's legacy vocabulary; no sandbox plan used either, unmeasured here

The dangerous half is the zero. cycle: 0 off the single read means "not populated here", not "every zero periods", and zero is a number that flows happily into arithmetic. Normalise both shapes into one field, prefer the word, and decode the number only when the word is absent.

THE INVOICE THAT MIGHT EMAIL YOUR CUSTOMER

Now the Keap order is paid and the books get written from it. According to Intuit's own documentation, creating a QuickBooks invoice by API can email the customer, with no send call anywhere in your code.

Four conditions, which the target company may already meet: online payments activated; a company setting under Sales for automatically sending imported invoices; a customer email on file; and the invoice having credit card or bank transfer (ACH) payment enabled. Only the last one is under payload control, and Intuit defaults those flags to the company's subscription status when they are omitted, so omitting them is the dangerous case. The company level opt in is not readable through the API at all.

So send AllowOnlineCreditCardPayment, AllowOnlineACHPayment and AllowIPNPayment false, EmailStatus: 'NotSet', and no BillEmail, whose Cc falls back to a company preference, so a third party can be copied on mail you never meant to send.

Then we tested it, on August 24, 2026, against a live company with QuickBooks Payments on and online delivery Enabled, with the owner's written permission. The control invoice had the online payment flags ON and a customer record holding our own email address. It was never emailed. EmailStatus stayed NotSet, DeliveryInfo stayed absent, and nothing arrived in the mailbox, checked both ways.

The owner then went looking for the "automatically send imported invoices" setting and could not find it anywhere. QuickBooks Online's own in product help answered that no such single setting exists: automatic sending applies only to invoices created by a Recurring template, never to invoices arriving from an app. That directly contradicts the developer documentation the four conditions came from.

This is evidence, not proof. The source contradicting Intuit's docs is an in product AI help answer, one company was tested, and Intuit changes QuickBooks Online without notice. So the write path still sends every flag false, still sends EmailStatus: 'NotSet', and still reads the invoice back to confirm nothing was delivered. A guard that holds on companies nobody tested is worth more than a proof that holds on one.

THE REST OF THE QUICKBOOKS TRAPS, IN ONE PAYLOAD

The invoice and its payment carry a handful of other rules, documented, scattered, and easy to violate in a way that reports success. Here they are in the shape you would send.

QuickBooks v3illustrative
POST /v3/company/{realm}/invoice
{
  "CustomerRef": { "value": "412" },            // the QBO customer matched to the Keap contact
  "TxnDate": "2026-08-14",                       // omitted means the server's today
  "Line": [
    { "DetailType": "SalesItemLineDetail", "Amount": 98.00,        // Amount is authoritative, compute it yourself
      "SalesItemLineDetail": { "ItemRef": { "value": "31" }, "Qty": 2, "UnitPrice": 49.00 } },
    { "DetailType": "SalesItemLineDetail", "Amount": 12.00,        // shipping is a LINE, not a field
      "SalesItemLineDetail": { "ItemRef": { "value": "SHIPPING_ITEM_ID" } } },
    { "DetailType": "DiscountLineDetail", "Amount": 10.00,         // POSITIVE, QuickBooks subtracts it
      "DiscountLineDetail": { "PercentBased": false } }
  ],
  "ApplyTaxAfterDiscount": true,                // default is tax first; most stores discount first
  "AllowOnlineCreditCardPayment": false,
  "AllowOnlineACHPayment": false,
  "AllowIPNPayment": false,
  "EmailStatus": "NotSet",                       // and NO BillEmail
  "PrivateNote": "Keap order 43126 / WooCommerce order 8812"   // internal, 4,000 chars; CustomerMemo is visible
}

// A 200 can still carry a Fault. Read the body, never res.ok.
{ "Fault": { "Error": [{ "Message": "...", "Detail": "...", "code": "6000", "element": "..." }] } }

POST /v3/company/{realm}/payment
{
  "CustomerRef": { "value": "412" },
  "TotalAmt": 100.00,                            // must equal the sum of Line.Amount, or the difference floats as credit
  "TxnDate": "2026-08-14",
  "PaymentRefNum": "keap_43126",                 // the ONLY queryable place for an external id
  "Line": [{ "Amount": 100.00, "LinkedTxn": [{ "TxnId": "1187", "TxnType": "Invoice" }] }]
}

A 200 can carry a Fault. Intuit's own status table says so: the request succeeded, however the body may contain a Fault element. Same shape as Keap's 201 with a failure inside it. Read the body every time.

Line.Amount is authoritative. QuickBooks does not compute it from quantity times unit price; those two are display. Compute it yourself, once per line.

Shipping and discounts are lines. Shipping is a sales item line whose item reference is the literal string SHIPPING_ITEM_ID, valid only when the company's AllowShipping preference is true. A discount is its own line with a positive amount that QuickBooks subtracts. ApplyTaxAfterDiscount defaults to tax first, which is not what most stores do.

Only send DocNumber when the company uses custom transaction numbers. It is capped at 21 characters, and sending one otherwise is documented to cause silent duplicates rather than an error.

PaymentRefNum is the only queryable external id. You cannot query Payments by the invoice they were applied to, and there are no joins. Put the Keap order id in PaymentRefNum or you will never be able to ask "did we already book this one".

TxnDate omitted means today. A backfilled payment silently lands on the day you ran the sync, which puts a year of history into one accounting period.

Three query rules. A query returns at most 1,000 rows however large you set MAXRESULTS, with no next page token, so clamp your own page size or a short page gets mistaken for the whole set. Strings escape with a backslash, not a doubled quote: 'Adam\'s Candy Shop'. A doubled quote closes the literal, opens another, matches nothing, and reads as "not in QuickBooks". And PrimaryPhone is not filterable: a like on it returns error 4001, so pull the customers and match phone in memory, which QuickBooks stores as free text anyway.

WHAT A CORRECT SYNC DOES, STEP BY STEP

Put the three traps together with the direction rule and the sync writes itself. This is the sequence we run.

  1. Find or create the Keap contact. Match the store customer to a contact by email before creating anything. The contact is the record every later fact hangs off.
  2. Resolve each line to a Keap product. Explicit association first, SKU second. A line that resolves neither way is refused and reported. It is never matched by name and never invented.
  3. Create the Keap order with product ids on the line items. It lands DRAFT. Treat that as half done.
  4. Record the payment with payment_amount and charge_now: false. The order flips to PAID and the customer's Product Purchase goal fires on Keap's own schedule, a few seconds later.
  5. Write the QuickBooks invoice from the Keap order, not from the store order. Customer matched to the contact by email, then phone in memory. Every Line.Amount computed once. Shipping and discount as lines. The three online payment flags false, EmailStatus NotSet, no BillEmail, the Keap order id in PrivateNote.
  6. Record the QuickBooks payment against the invoice, TxnDate set to the day the money moved, PaymentRefNum carrying the Keap order id so it can be found again.
  7. Read everything back. Check every body for a Fault whatever the status code. Read the invoice to confirm EmailStatus stayed NotSet. Check the Keap order reads PAID.
  8. Count the refusals out loud. An order that could not be booked or a line that would not resolve is reported as a number, not dropped. Refused money that reads as synced is the two writer problem back through the side door.

Run that and the numbers agree, not because a reconciliation job fixed them afterwards, but because they only came from one place. The bookkeeper's total and the CRM's total are the same fact read twice. And the customer who bought the pants gets the email about the pants, on the contact record that also shows what they paid.

COMMON QUESTIONS

Why do my WooCommerce sales in Keap not match QuickBooks?
Because two plugins each wrote their own copy of every sale and nothing reconciled the copies. Refunds reach one side, subscription renewals charged in Keap never pass through the store, and a renamed product matches one system by SKU and the other by name. The fix is a direction of flow, not a third plugin: store to CRM, CRM to books.
Why did my purchase automation not fire?
The order was created and never paid. POST /v1/orders lands DRAFT with total_paid: 0, and Keap's Product Purchase goal fires only on a recorded payment. We measured it: no tag twelve seconds after the draft, the tag roughly twenty seconds after the payment call. No payment, no sequence, no error.
Should the store write to QuickBooks directly?
No. It becomes a second writer whose sales never met the customer record. The QuickBooks customer it creates comes from a billing address, not from the contact, and nothing joins the two. Write QuickBooks from the Keap order and the invoice carries the same person the CRM knows.
Will creating an invoice by API email my customer?
Intuit's docs say it can. On a live company with payments on, our control invoice with the flags ON was never emailed, and the product's own help said no such setting exists for invoices created by an app. That is evidence from one company, not proof, so send the three online payment flags false, EmailStatus NotSet, no BillEmail, and confirm on the read back.
What if my products have no SKU?
Then the SKU is not your key. One live catalogue carried a SKU on 49 of 53 products, another on 1 of 5. Use an explicit product association first and the SKU as the fallback, and refuse a line that resolves neither way. Matching on name is not the answer: one of the SKU less products we measured is called "Services".
THIS IS THE SYNC WE REBUILT

We built it into MyVitalAssistant because the client needed it running, not diagrammed. We have been connecting Keap to the rest of the stack since 2010. If your books and your CRM tell two stories about the same month, that is a conversation worth having.

Book a Free Consult
Attribution that survives
02WooCommerce to Keap to QuickBooks: one source of truth, not two writers· you are here