Skip to content
Desktop Accounting API

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.

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.

In the dashboard, open Webhooks and click Add endpoint. Or create one with the API:

Terminal window
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 https on 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.
  • eventTypes lists the events to send. includeSyncRequests (default false) 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}/test sends one signed webhook.test event 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.

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.

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();
});
  • Answer with any 2xx status 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: false with a disabledReason). 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.

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.

  • Batch import. Send each write with Prefer: respond-async and an idempotency key derived from your record. Store the returned request ID with the record. On request.succeeded, fetch the result and save the QuickBooks ID. On request.failed, show the error on the record.
  • Connection monitoring. On connection.status_changed to offline or quickbooks_unavailable, show a banner in your product for that customer with the help center link. Clear it when the status returns to online.
  • Onboarding. On connection.setup_completed, start the customer’s first sync.