Webhooks
Webhooks tell your server when something happens, so you do not have to poll. They are most useful with async requests: queue a batch of writes, then process each request.succeeded or request.failed event as it arrives.
Deliveries follow the Standard Webhooks specification, so you can verify them with existing open-source libraries in most languages.
Event types
Section titled “Event types”| Type | Fires when |
|---|---|
request.succeeded |
A request reached succeeded |
request.failed |
A request reached failed |
request.canceled |
An async request was canceled |
request.outcome_unknown |
A write was sent and its result could not be confirmed |
connection.setup_completed |
An end user finished the setup flow and the first health check passed |
connection.status_changed |
A connection’s status changed, for example online to offline |
webhook.test |
You sent a test event from the dashboard or the API |
Request events fire for async requests. For sync requests they fire only when your HTTP call did not get the final answer (it timed out after sending, or your client disconnected), or when a write became outcome_unknown. To receive events for every sync request too, set includeSyncRequests: true on the endpoint.
New event types can be added. Ignore types you do not handle and answer 2xx.
Add an endpoint
Section titled “Add an endpoint”In the dashboard, open Webhooks and click Add endpoint. Or create one with the API:
curl https://api.desktopaccountingapi.com/v1/webhook-endpoints \ -H "Authorization: Bearer $DAAPI_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://app.example.com/webhooks/quickbooks","eventTypes":["request.succeeded","request.failed","connection.status_changed"],"description":"Production sync worker"}'The response includes the signing secret (whsec_...) once. Store it in your secret manager; later reads of the endpoint do not return it.
- The URL must be
httpson the default port, on a public DNS name. IP addresses, credentials in the URL, internal host names and our own hosts are rejected. Before every delivery we resolve the host again and refuse private, loopback and other non-public addresses. eventTypeslists the events to send.includeSyncRequests(defaultfalse) adds request events for sync calls.- A project can have up to 20 endpoints (
422 WEBHOOK_ENDPOINT_LIMIT_REACHED). Test and production projects have separate endpoints. POST /v1/webhook-endpoints/{id}/testsends one signedwebhook.testevent right away and returns the delivery, so you can check your verification code.
| Operation | Request |
|---|---|
| List, retrieve | GET /v1/webhook-endpoints, GET /v1/webhook-endpoints/{id} |
| Update (URL, events, description, enable) | POST /v1/webhook-endpoints/{id} |
| Delete (with its delivery log) | DELETE /v1/webhook-endpoints/{id} |
| Rotate the secret | POST /v1/webhook-endpoints/{id}/rotate-secret |
| Send a test event | POST /v1/webhook-endpoints/{id}/test |
| Delivery log | GET /v1/webhook-endpoints/{id}/deliveries |
| Resend a delivery | POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/resend |
The dashboard’s Webhooks page does the same: add an endpoint, edit its URL and events, enable or disable it, send a test event, rotate the secret, resend a delivery and delete the endpoint. Each endpoint’s page shows its newest 50 deliveries with their attempts.
Payload
Section titled “Payload”Payloads are small. They identify what happened; fetch details through the API.
{ "id": "evt_01j9x7c2m4p6r8t0v2x4z6b8dc", "type": "request.succeeded", "timestamp": "2026-10-05T16:04:01.311Z", "projectId": "proj_01j9...", "data": { "objectType": "request", "id": "req_01j9x4m6v4c8k2t7q0r5s3w1zb", "status": "succeeded", "operationId": "qbd.invoices.create", "endUserId": "eu_01j9...", "outcome": "applied", "error": null }}For request events, data is the request resource as GET /v1/requests/{id} showed it at the event, without result and timeline. It keeps error and diagnosis. Get the result with GET /v1/requests/{id}. Connection events carry objectType: "connection_event", connectionId, endUserId, status, previousStatus, reason and error; connection.setup_completed adds connectorId and companyName. Payloads never contain accounting data.
Verify signatures
Section titled “Verify signatures”Every delivery has three headers:
| Header | Value |
|---|---|
webhook-id |
The event ID (evt_...). The same for every retry of one event. |
webhook-timestamp |
Unix seconds when this attempt was signed. |
webhook-signature |
v1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with your secret. During a secret rotation, two signatures separated by a space. |
Verify against the raw request body before parsing JSON, and reject timestamps more than five minutes from your clock.
import { DesktopAccountingApi } from "@desktopaccountingapi/quickbooks-desktop";import express from "express";
const client = new DesktopAccountingApi();const app = express();
app.post("/webhooks/quickbooks", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = await client.webhooks.verify(req.body, req.headers, process.env.DAAPI_WEBHOOK_SECRET!); } catch { return res.status(400).end(); } // queue the work, answer fast res.status(204).end();});import osfrom flask import Flask, requestfrom desktopaccountingapi import WebhookVerificationError, webhooks
app = Flask(__name__)
@app.post("/webhooks/quickbooks")def quickbooks_webhook(): try: event = webhooks.verify(request.get_data(), request.headers, os.environ["DAAPI_WEBHOOK_SECRET"]) except WebhookVerificationError: return "", 400 # queue the work, answer fast return "", 204Any Standard Webhooks library works. For example, with the standardwebhooks package:
from standardwebhooks.webhooks import Webhook
payload = Webhook(secret).verify(raw_body, headers)To verify by hand: compute base64(HMAC_SHA256(base64decode(secret without "whsec_"), id + "." + timestamp + "." + body)) and compare it in constant time with each v1, signature in the header.
Delivery and retries
Section titled “Delivery and retries”- Answer with any
2xxstatus within 15 seconds. Do slow work after answering. - Redirects are not followed. Point the endpoint at the final URL.
- Failed deliveries retry after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: eight attempts over about 27 hours.
- Delivery is at least once and not ordered. Deduplicate on
webhook-id, and read the current state from the API when order matters. - After the last attempt the delivery is marked
failed. It stays in the delivery log, and you can resend it. - An endpoint that fails every delivery for 5 days in a row is disabled (
enabled: falsewith adisabledReason). The dashboard shows it as Disabled with the reason. Enable it again in the dashboard or with an update once your server is fixed.
Every attempt is kept for 30 days with its status code, latency, the first 512 characters of your response and, when it failed without a response, the reason (timeout, network_error, redirect_not_followed, blocked_destination or non_2xx_status). Resend any delivery through the API; it keeps the same webhook-id.
Rotate the signing secret
Section titled “Rotate the signing secret”Call POST /v1/webhook-endpoints/{id}/rotate-secret. The response holds the new secret once. For 24 hours, deliveries carry signatures for both the old and the new secret, so you can deploy the new secret without losing events.
Patterns
Section titled “Patterns”- Batch import. Send each write with
Prefer: respond-asyncand an idempotency key derived from your record. Store the returned request ID with the record. Onrequest.succeeded, fetch the result and save the QuickBooks ID. Onrequest.failed, show the error on the record. - Connection monitoring. On
connection.status_changedtoofflineorquickbooks_unavailable, show a banner in your product for that customer with the help center link. Clear it when the status returns toonline. - Onboarding. On
connection.setup_completed, start the customer’s first sync.