Sync QuickBooks Webhooks
Time: ~10 minutes | Difficulty: Beginner | Prerequisites: An app in the Intuit Developer dashboard, Slashbin account
TL;DR: QuickBooks breaks two habits every other webhook source teaches you. Its payload is a top-level array of CloudEvents notifications, not an object — the topic lives at $[0].type. And its secret is a Verifier Token, not a signing key, which is why hunting the Intuit dashboard for "signing secret" turns up nothing. Both become stored configuration in Slashbin instead of parsing and crypto code you maintain.
The Problem
Accounting events are the ones you least want to guess about. An invoice was created, a payment was applied, a customer record changed — and something downstream is supposed to know. What actually goes wrong first has nothing to do with your business logic:
- Your transform finds nothing, and nothing errors. You wrote the mapping against an object. QuickBooks sent an array. A JSONPath into an object shape on an array payload doesn't throw — it returns empty, and an empty result looks like a quiet week rather than a bug.
- You never flipped the CloudEvents toggle. The array shape only arrives if the "cloud event payload format" switch is on for your app. Leave it off and QuickBooks posts its older object shape, which is a different payload with different field names — see Step 3.
- The secret you're looking for doesn't exist under that name. Every other provider hands you something called a signing secret or a signing key. Intuit calls its value a Verifier Token, and it lives in the app's webhooks settings rather than anywhere labelled "signing."
- One request is several events — sometimes for several companies. QuickBooks batches notifications, which is why the payload is an array. Code written for one-event-per-request quietly processes the first element and drops the rest.
- You reused the check that worked for another base64 provider. Understandable — and wrong in a way that fails closed. See the callout in Step 1.
None of these produce a clean error. They produce silence, which is the expensive kind of failure when the missing records are financial.
The Solution
Route QuickBooks through a Slashbin project. Slashbin becomes the endpoint Intuit posts to, and the array shape and the token naming stop being your problem:
- The raw payload exactly as QuickBooks sent it — bytes and headers, array and all.
- Signature verification at the gateway, applied from stored configuration rather than crypto code in your app.
- Routing on the CloudEvents event type at
$[0].type, as a rule instead of an index into an array at the top of your controller. - The transform result — the flat records your Golden Model produced, or the error it threw.
- Every delivery attempt downstream, with response code, response body, and timing — plus replay from Slashbin's own storage.
Outcome: Intuit only has to reach Slashbin once. Everything after that is inspectable and re-runnable on your schedule.
Step 1: Create a QuickBooks Project in Slashbin
- Click Create New Project and select QuickBooks.
- Type: Choose Transactional — an entity change is a discrete business event, not a metric.
- Leave the secret field for Step 2; you need to fetch it from Intuit first.
Selecting QuickBooks loads its verification contract into the project:
| Field | QuickBooks' value |
|---|---|
| Auth type | HMAC |
| Secret field | Verifier Token |
| Signature header | intuit-signature |
| Signature encoding | base64 |
| Signature prefix | none |
| Signed payload | the raw request body |
| Event topic | $[0].type, read from the body — the CloudEvents event type |
Slashbin's gateway has no QuickBooks-specific code path. QuickBooks works because that contract is stored and applied on every request — the same mechanism that verifies Stripe, Square, Shopify, GitHub and Typeform.
Base64 is not the whole contract — the prefix is part of it. Typeform also sends a base64 digest, but with a sha256= prefix on the header value. QuickBooks sends base64 with no prefix at all. If you implemented Typeform first and reused the check, you will be comparing a bare digest against one you stripped a prefix from — or stripping a prefix that was never there. Same encoding, different header shape, and the failure looks exactly like a wrong secret. Meanwhile GitHub uses that same sha256= prefix over a hex digest. Three providers, three combinations.
Step 2: Paste the Verifier Token From the Intuit Developer Dashboard
- Open the Intuit Developer (opens in a new tab) dashboard and select your app.
- Go to the app's webhooks settings.
- Copy the Verifier Token.
- Paste it into the Verifier Token field on your Slashbin project.
That is the whole secret exchange. There is no separate signing key to generate and nothing to configure on the crypto side — Slashbin uses the Verifier Token as the HMAC key, computes the digest over the raw body, base64-encodes it, and compares it to intuit-signature.
If you are looking for a "signing secret," stop looking. Intuit does not ship one under that name. The Verifier Token is the shared secret, and it is the field Slashbin's wizard asks for. This single naming difference is a common reason a QuickBooks integration stalls before the first request.
Rotating the token later means updating it in both places. Until both sides match, verification fails closed and the rejections are visible in the Delivery Log.
Step 3: Turn On CloudEvents and Point Intuit at the Slashbin Ingestion URL
- Copy the Ingestion URL from the Slashbin project dashboard.
- In the Intuit Developer dashboard, open your app's webhooks subscription page.
- Enable the "cloud event payload format" toggle. This is not optional — see the callout below.
- Set the webhook endpoint URL to your Slashbin Ingestion URL.
- Select the entities you want notifications for — Invoice, Customer, Payment, and so on.
- Save, then trigger a change in a sandbox company to produce a real notification.
- Open the Slashbin Delivery Log. A verified delivery confirms the Verifier Token matches on both sides before anything downstream depends on it.
The CloudEvents toggle is a required setup step, not a preview opt-in. Intuit announced the payload change in November 2025 with an original cutover of May 15, 2026, later extended to July 31, 2026. That date has passed — anyone standing this up today is on CloudEvents, and it is the format Slashbin's QuickBooks configuration is written against.
If the toggle is off, QuickBooks posts its legacy object payload — a {"eventNotifications": [...]} envelope, not an array. $[0].type matches nothing on it, so the delivery still verifies and still stores, but the topic resolves to unknown and your routing rules never fire. That failure is quiet: signature green, delivery green, nothing routed.
Look at the stored raw body on that first delivery rather than assuming the shape. If the first character is [, the toggle is on. If it is {, go back to step 3.
Step 4: Route on the CloudEvents Type and Deliver
The topic is an index into an array
This is the fact most likely to cost you an afternoon. Every other source documented here reads its topic from an object path in the body (Typeform's event_type) or from a header (GitHub's x-github-event). QuickBooks reads it from $[0].type — the type field of the first element of a top-level array.
This is Intuit's published CloudEvents shape, batched — two notifications, two different companies, in one request:
[
{
"specversion": "1.0",
"id": "88cd52aa-33b6-4351-9aa4-47572edbd068",
"source": "intuit.dsnBgbseACLLRZNxo2dfc4evmEJdxde58xeeYcZliOU=",
"type": "qbo.invoice.created.v1",
"datacontenttype": "application/json",
"time": "2025-09-10T21:31:25.179851517Z",
"intuitentityid": "1234",
"intuitaccountid": "310687",
"data": {}
},
{
"specversion": "1.0",
"id": "0f2b1c4e-9d63-4a1f-8f0e-6c3a2b7d5e91",
"source": "intuit.7Ykq2LbVwPmHt4XcRzA9eJnDgS1uFoIe0MvNbQyKlWs=",
"type": "qbo.customer.updated.v1",
"datacontenttype": "application/json",
"time": "2025-09-10T21:31:26.004112730Z",
"intuitentityid": "45",
"intuitaccountid": "4620816365213739990",
"data": {}
}
]A JSONPath written for an object — $.type, $.event_type — resolves to nothing here. It will not error. It will simply never match a routing rule, and the events will pile up unrouted while every dashboard reads green.
The topic value is qbo.invoice.created.v1, not Invoice
Three things follow from the payload above, and each one is a rule people get wrong on the first pass:
- Route on the full event type. The topic is the literal CloudEvents
typestring —qbo.invoice.created.v1,qbo.payment.created.v1,qbo.customer.updated.v1. A rule matchingInvoicenever fires. If you want every invoice change regardless of operation, match on theqbo.invoice.prefix rather than an exact string. - The operation is inside the type. There is no separate
operationfield on a CloudEvents notification. Created, updated and deleted are the third segment oftype. - The identifiers are top level, not nested.
intuitaccountidis the QuickBooks company (realm) id andintuitentityidis the id of the record that changed. Both sit besidetype, not underdata.
data is empty, and that is by design. Intuit's published CloudEvents example ships data as {} — the notification tells you what changed, not what it changed to. Treat the envelope as a pointer: take intuitaccountid and intuitentityid, work out the entity from type, and read the current record from the QuickBooks API. Do not build a Golden Model that depends on fields inside data.
Batching is why it's an array
QuickBooks does not send one request per changed record. It batches notifications and posts them together, and one notification can carry events for more than one QuickBooks company. Three consequences worth designing around:
- Routing sees the first element only.
$[0].typeis the topic, so the request above routes asqbo.invoice.created.v1even though a customer update is riding along behind it. Route on the topic to select the pipeline, and let the transform fan the array out inside it. - Do not stamp the batch with the first element's realm.
$[0].intuitaccountidis the company of the first event, not of the request. If you connect more than one company — a subsidiary, a sandbox, a client's books — reading the realm once per request will file the second company's invoices under the first company's books. Readintuitaccountidper element. - One stored event can carry several changes. In the Delivery Log, a single entry is not necessarily a single change. When you are reconciling counts — "we saw 40 events but 57 invoices moved" — this is usually the answer, not a dropped delivery.
Map the batch into flat records
Define the shape your warehouse or accounting sync wants once, in the Transformation IDE, instead of walking the array in every consumer. The paths below are the CloudEvents envelope fields, shown against the first element:
| Target field | Source JSONPath | Notes |
|---|---|---|
event_type | $[0].type | Required — same path the topic uses. Entity and operation both live here |
event_id | $[0].id | The CloudEvents event id. Deduplicate on it — this is not the entity id |
entity_id | $[0].intuitentityid | Required — top level, not under data. The id of the record that changed |
realm_id | $[0].intuitaccountid | Required — which QuickBooks company this event belongs to |
event_time | $[0].time | ISO 8601. CloudEvents calls it time, not lastUpdated |
specversion and datacontenttype are constants and carry no information worth storing. source is an opaque hashed identifier — it is stable per company, but intuitaccountid is the field to key on.
Pro tip: Always carry intuitaccountid through. It is the QuickBooks company identifier, and if you ever connect a second company it is the only field that tells two otherwise-identical invoices apart. Adding it later means backfilling.
Toggle Required ON for event_type, entity_id, and realm_id. A record you cannot attribute to a company and an entity is not one your ledger should accept.
Click Publish to activate the model. Unpublished models don't drop events: incoming notifications are captured in the Dead Letter Queue until a matching model is live, so nothing is lost while you iterate.
Deliver, retry, replay
On the Outbound tab, click + Add Destination and point it at whatever should own the change — your warehouse loader, an internal accounting sync, or both. From there the guarantees are Slashbin's:
- Transient failure → retry with backoff. Your loader returning 502 for ninety seconds costs you nothing.
- Exhausted attempts → the Dead Letter Queue, holding the original raw array, not a summary of it.
- Fixed the mapping → Replay. Slashbin re-delivers from its own storage, through the current model. Correct the entity mapping on Thursday and re-run Monday's notifications through it.
Why Slashbin vs a Raw Handler
- The array shape is configuration, not parsing code.
$[0].typeis a stored topic path. Nobody on your team has to remember that QuickBooks is the odd one out. - The base64-without-a-prefix trap disappears.
intuit-signature, base64, no prefix, raw body — applied at the gateway. You never write the check, so you never write it wrong. - The Verifier Token is asked for by name. The wizard uses Intuit's label, so there is no translation step between the dashboard you copy from and the field you paste into.
- A wrong payload format is visible in seconds. The raw body is stored intact, so "did the CloudEvents toggle actually take" is one look at the Delivery Log rather than an afternoon of instrumenting your handler.
- Batches are visible. The raw payload is stored intact, so "how many events were in that request, and for which companies" is something you can look up rather than infer.
- Nothing is a silent drop. Failed deliveries surface in the DLQ with the full payload, and replay runs on your schedule rather than inside Intuit's retry window.
Next Steps
- Compare the base64 contract that does carry a prefix — Send Typeform Submissions Anywhere.
- See the hex side of
sha256=— Debug and Route GitHub Webhooks. - Verify signatures for another payments source — Verify Square Webhook Signatures.
- Go deeper on mapping and validation — read Golden Model.
- Send one notification to more than one system — see Fan-Out Routing.
- Browse every supported source and destination on Integrations.