Gareth Wilson Gareth Wilson

Guide to QuickBooks Webhooks: Features and Best Practices

Published


QuickBooks Online webhooks tell your app when something changes in the books of a company it is connected to: an invoice is created, a payment is recorded, a customer is merged, a bill is voided. If you build on Intuit's QuickBooks Online Accounting API, webhooks are how those changes reach your own systems without polling every company you serve.

This guide covers how QuickBooks Online webhooks are configured and signed, the CloudEvents payload and the legacy format it replaced, the gaps and contradictions in Intuit's documentation, and the best practices for running a QuickBooks receiver in production.

What are QuickBooks webhooks?

A QuickBooks Online webhook is a POST from Intuit to an endpoint URL you set on your app in the Intuit Developer dashboard. You choose which entities and operations you care about, such as Invoice Create or Customer Merge, and Intuit notifies you whenever one of them happens in a company that has authorised your app through OAuth 2.0. Intuit calls each company a realm, and the realm ID travels with every event.

Notifications are thin. They tell you which record changed, in which realm, and how, but they do not carry the record itself. You fetch the current data from the Accounting API, or catch up with a ChangeDataCapture (CDC) query.

Every notification is signed. Intuit computes an HMAC-SHA256 of the raw request body with your app's Verifier Token, base64-encodes it, and sends it in the intuit-signature header. Nothing else is signed.

The payload format is in the middle of a migration. The current docs describe a CloudEvents JSON array. Apps built before the change received a single JSON object with an eventNotifications key, and Intuit's own migration prompt says that "during the migration window, Intuit may send either format". The signature works the same way for both.

QuickBooks webhook features

FeatureDetails
ConfigurationIntuit Developer dashboard: My Hub > App dashboard > your app > Webhooks. No subscription API
EnvironmentsDevelopment (sandbox) and Production are configured separately, each with its own URL, events and Verifier Token
Subscription modelEntity and operation checkboxes under Show webhooks, across 29 entities and six operations
Signature headerintuit-signature
SchemeHMAC-SHA256, standard base64 with padding
Signed contentThe raw request body, nothing else
SecretThe Verifier Token, one per app per environment, used as a UTF-8 string
Replay windowNone. No timestamp is signed
PayloadA CloudEvents JSON array (current), or a legacy eventNotifications object
Response windowHTTP 200 within 3 seconds
Retries10s, 20s, 30s, 5m, 20m, 2h, 4h, 6h, then every 6 hours
OrderingNot guaranteed. Later events can be held back until a failed one is acknowledged
Payload sizeNo Intuit-imposed limit on size or number of events
Source IPsNot published

You only receive events for companies that have completed the OAuth 2.0 flow with your app. That includes sandbox companies, which is easy to forget when testing.

Common events

The type of each event follows the pattern namespace.entitytype.eventname.version. Intuit's current pages print four complete type strings:

TypeFires whendata
qbo.account.created.v1An account is created{}
qbo.customer.merged.v1A customer is merged into another{"deletedid": "<id merged away>"}
qbo.employee.created.v1An employee is created{"alternative_ids": [{ "id", "namespace" }]}
qbo.salesreceipt.updated.v1A sales receipt is updatedAbsent

The subscription table is much larger. These are the entities that support webhooks, with the operations each one allows:

OperationsEntities
Create, Update, Delete, MergeAccount, Class, Customer, Employee, Item, Vendor
Create, Update, Delete, Void, EmailedCreditMemo, Invoice, Payment, RefundReceipt, SalesReceipt
Create, Update, Delete, VoidBillPayment, Purchase, Transfer
Create, Update, Delete, EmailedEstimate, PurchaseOrder
Create, Update, DeleteBill, Deposit, JournalEntry, TimeActivity, VendorCredit
Create, Update, MergeDepartment, PaymentMethod
Create, UpdateBudget, Currency, JournalCode, TaxAgency, Term
UpdatePreferences

Intuit does not say how an operation becomes the eventname part of the type. The examples confirm that Create becomes created, Update becomes updated and Merge becomes merged, and that entity names are lowercased (salesreceipt). The strings for Delete, Void and Emailed are not published. Treat the operation names as a guide and confirm the exact strings from your first real deliveries.

Here is Intuit's complete example notification:

[
  {
    "specversion": "1.0",
    "id": "88cd52aa-33b6-4351-9aa4-47572edbd068",
    "source": "intuit.dsnBgbseACLLRZNxo2dfc4evmEJdxde58xeeYcZliOU=",
    "type": "qbo.account.created.v1",
    "datacontenttype": "application/json",
    "time": "2025-09-10T21:31:25.179851517Z",
    "intuitentityid": "1234",
    "intuitaccountid": "310687",
    "data": {}
  }
]

The body is an array, and each element is its own event. id is unique per event, intuitentityid is the ID of the record that changed, intuitaccountid is the realm ID, and time is when the change happened, in RFC 3339 at UTC. datacontenttype and data can both be missing, as in the salesreceipt.updated example. Intuit says that "a single notification can contain a list of events, each for a different QuickBooks Online company/realm ID", so read the realm from each element rather than once per request.

The legacy eventNotifications format

Apps that have not switched to CloudEvents receive a JSON object instead of an array. No current Intuit page shows a complete example, but Intuit's migration prompt and its Java SDK models describe the shape:

{
  "eventNotifications": [
    {
      "realmId": "<realm id>",
      "dataChangeEvent": {
        "entities": [
          { "name": "Customer", "id": "<id>", "operation": "Create", "lastUpdated": "<timestamp>" }
        ]
      }
    }
  ]
}

Here the entity name and the operation are separate fields, in Intuit's capitalised spelling (Customer, Create), and one realm can carry several entities. The SDK model also has a deletedId field on each entity. The same intuit-signature check covers this format, so only your parsing changes.

Intuit's migration prompt treats the switch as a per-app setting in the Developer Portal ("Enable CloudEvents"), and the date by which the legacy format stops being sent is not stated on any page we could read. Until you know your app only receives CloudEvents, handle both shapes.

Setting up QuickBooks webhooks

  1. Set up OAuth 2.0 for your app if you have not already. Notifications only arrive for companies that have authorised it.
  2. In the Intuit Developer dashboard, select My Hub > App dashboard and open your app.
  3. Select Webhooks in the left navigation, then Development or Production.
  4. Enter your Endpoint URL.
  5. Open the Show webhooks dropdown and select the entities and operations you want, then select Save.
  6. On the same page, copy the Verifier Token and store it server-side, for example as QUICKBOOKS_WEBHOOK_VERIFIER_TOKEN.

Repeat the steps for the other environment. The two configurations are independent, so a sandbox token will not verify production notifications. Intuit notes that the first notification can take up to five minutes to arrive.

Securing QuickBooks webhooks

Read the intuit-signature header, compute an HMAC-SHA256 of the raw request body using the Verifier Token as the key, base64-encode the digest, and compare the two in constant time. Base64 is case-sensitive, so compare the strings exactly.

const crypto = require('crypto');
const express = require('express');

function verifyQuickBooksSignature(rawBody, signatureHeader, verifierToken) {
  if (!signatureHeader || !verifierToken) return false;

  const expected = crypto
    .createHmac('sha256', verifierToken)
    .update(rawBody)
    .digest('base64');

  const received = Buffer.from(signatureHeader, 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

// Match every content type: CloudEvents deliveries may arrive as
// application/cloudevents+json, which a JSON-only parser would skip.
app.post('/webhooks/quickbooks', express.raw({ type: '*/*', limit: '10mb' }), (req, res) => {
  const ok = verifyQuickBooksSignature(
    req.body,
    req.get('intuit-signature'),
    process.env.QUICKBOOKS_WEBHOOK_VERIFIER_TOKEN
  );
  if (!ok) return res.sendStatus(401);

  const body = JSON.parse(req.body.toString('utf8'));
  // CloudEvents: an array of events. Legacy: an object with eventNotifications.
  const events = Array.isArray(body) ? body : body.eventNotifications ?? [];
  // Persist the events, then process them asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import base64
import hashlib
import hmac
import os


def verify_quickbooks_signature(raw_body: bytes, signature_header, verifier_token: str) -> bool:
    if not signature_header or not verifier_token:
        return False
    expected = base64.b64encode(
        hmac.new(verifier_token.encode("utf-8"), raw_body, hashlib.sha256).digest()
    )
    # Compare as bytes: compare_digest raises TypeError on a str with non-ASCII characters.
    return hmac.compare_digest(signature_header.encode("utf-8"), expected)


# In FastAPI: raw_body = await request.body()
# verify_quickbooks_signature(raw_body, request.headers.get("intuit-signature"),
#                             os.environ["QUICKBOOKS_WEBHOOK_VERIFIER_TOKEN"])

Intuit's own Java sample does the same thing: it keys the HMAC with verifier.getBytes("UTF-8"), base64-encodes the result and compares it with the header. There is no SDK helper for webhook verification in Intuit's libraries, so every receiver verifies by hand.

To check your verifier, use the Verifier Token quickbooks-test-verifier-token and this exact body:

[{"specversion":"1.0","id":"88cd52aa-33b6-4351-9aa4-47572edbd068","type":"qbo.account.created.v1","intuitentityid":"1234","intuitaccountid":"310687"}]

The intuit-signature value should be:

PTs9BSwjYqOgFhLMBnVFCOlBZ+EC6WWqVDBTUoSfxH8=

Make QuickBooks webhooks production-ready. Hookdeck Event Gateway verifies the intuit-signature header with your Verifier Token, acknowledges Intuit well inside the 3-second window, and durably queues every notification before your service sees it.

QuickBooks webhook limitations and pain points

A three-second window, and a stalled stream when you miss it

The Problem: A slow or failing endpoint can hold up later notifications as well as the one that failed.

Why It Happens: Intuit expects HTTP 200 within 3 seconds. A failed notification is retried at 10s, 20s, 30s, 5m, 20m, 2h, 4h, 6h and then every 6 hours, and Intuit warns that "you may not receive subsequent events from our system until you correctly acknowledge the first event." Intuit's migration prompt adds that a 400 marks the delivery as failed and retries the entire batch.

Workarounds:

  • Verify, store the raw body, return 200 and process from a queue. Intuit recommends exactly this.
  • Do not return an error for an event you cannot process yet. Store it and deal with it later, so one bad event does not hold up the rest.
  • Run a CDC query for the entities you track, dating back to the last event you processed successfully, and consider a daily CDC sweep as Intuit suggests.

How Hookdeck Can Help: Hookdeck accepts each notification as soon as it arrives and stores it, then delivers it to your service with its own retries. A slow deploy or an outage on your side no longer blocks Intuit's delivery queue.

Two payload shapes on one endpoint

The Problem: A handler written for one format silently ignores the other.

Why It Happens: CloudEvents bodies are a JSON array. Legacy bodies are an object with eventNotifications. Code that reads body.eventNotifications sees nothing in a CloudEvents delivery, and code that iterates the body as an array fails on a legacy one. Intuit says either format may arrive during the migration window.

Workarounds:

  • Branch on Array.isArray(body) and normalise both shapes into one internal event, such as { realm_id, entity, operation, entity_id }.
  • Log any delivery that matches neither shape instead of returning an error.
  • Once your app only receives CloudEvents, remove the legacy branch.

How Hookdeck Can Help: Hookdeck keeps the full headers and raw body of every request, so you can see which format your app is receiving today and replay real legacy and CloudEvents deliveries through your handler while you migrate.

The content type may not be application/json

The Problem: Every request fails verification even though the token is right.

Why It Happens: Intuit's sample headers show application/json; charset=utf-8, but its migration prompt warns that CloudEvents payloads "may arrive with Content-Type: application/cloudevents+json". A body parser restricted to application/json skips those requests, leaving no raw body to verify. Large batches can also hit a server's default body-size limit, and a truncated body never verifies.

Workarounds:

  • Read the raw body for every content type on the webhook route, as express.raw({ type: '*/*' }) does above.
  • Raise the body-size limit on that route. Intuit sets no limit of its own.

How Hookdeck Can Help: Hookdeck treats an application/cloudevents+json delivery as JSON like any other and checks intuit-signature before forwarding it, so the content type stops being something your own parser has to get right.

Event strings you have to discover

The Problem: You cannot write a complete list of type values from the docs.

Why It Happens: Intuit tables 29 entities and up to six operations each, but prints only four complete type strings. How Delete, Void and Emailed are spelled in type is not documented.

Workarounds:

  • Route on the entity and the event segment separately, and log any type you have not seen before.
  • Subscribe to a few operations in a sandbox company, trigger each one, and record the types that arrive.

How Hookdeck Can Help: Hookdeck filters can route on any part of the body, so you can send known types to your handler and anything unfamiliar to a separate destination for review.

The docs contradict each other

The Problem: Following one page leads you to a wrong assumption from another.

Why It Happens: The Configure page says one notification can hold events for several realms, while the best-practices page says notifications "are sent one realm ID at a time". The best-practices page tells you to order events by a timestamp field, but the payload has time (or lastUpdated in the legacy format), not timestamp. Every documented data is empty or small, while Intuit's migration prompt says CloudEvents "includes the full entity payload in data".

Workarounds:

  • Handle several realms per request, whichever page is right.
  • Order by time in CloudEvents and lastUpdated in the legacy format.
  • Treat data as optional and fetch the record from the API.

How Hookdeck Can Help: Every request Hookdeck receives is searchable with its full body, so you can check what your app really receives instead of choosing between conflicting pages.

Best practices

Verify against the raw body

The signature covers the exact bytes Intuit sent. Parsing the JSON and serialising it again changes whitespace and key order, and the HMAC will not match. In a Next.js route handler, read await request.text() or Buffer.from(await request.arrayBuffer()). In FastAPI, read await request.body().

Use the right token for the right environment

Development and Production each have a Verifier Token. Name the environment variable after the environment, and point your sandbox endpoint and your production endpoint at different routes or different deployments so the tokens are never mixed up.

Iterate, and read the realm per event

Loop over the array and take intuitaccountid from each element. Look up the OAuth tokens for that realm before calling the API, and skip events for realms your app no longer serves rather than failing the whole request.

Deduplicate deliveries

Retries resend the same notification. In CloudEvents, store each event's id and skip IDs you have already processed. The legacy format has no event ID, so build a key from realmId, the entity name, id, operation and lastUpdated. See our guide to webhook idempotency.

Order by time, and fetch the current record

Events can arrive out of sequence. Compare each event's time with the last one you applied for that record, and when in doubt fetch the record from the API. Because notifications are thin, the API read is usually needed anyway.

Acknowledge fast and process asynchronously

Three seconds leaves little room for API calls. Verify, persist, return 200, and do the work from a queue. See why to process webhooks asynchronously.

Reconcile with ChangeDataCapture

Webhooks can be missed. Intuit recommends a CDC call for every entity you track, going back to the last event you processed, plus a daily CDC sweep. Treat webhooks as the fast path and CDC as the safety net.

Conclusion

QuickBooks Online signs every notification with a base64 HMAC-SHA256 of the raw body in the intuit-signature header, keyed with the Verifier Token for that environment. Read the raw body for any content type, handle both the CloudEvents array and the legacy object until your app has migrated, take the realm from each event, and answer within three seconds so one failure does not hold up the notifications behind it.

Hookdeck Event Gateway sits in front of your receiver, checks every intuit-signature, answers Intuit straight away, and holds each notification until your service has processed it, so an outage on your side costs latency instead of a stalled delivery queue.

Get started with Hookdeck for free and handle QuickBooks webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.