# Webhooks
Source: https://www.desktopaccountingapi.com/docs/guides/webhooks/

> Receive signed events when requests finish and connections change, verify them with Standard Webhooks, and handle retries and duplicates.

Webhooks tell your server when something happens, so you do not have to poll. They are most useful with [async requests](https://www.desktopaccountingapi.com/docs/guides/request-lifecycle/#async-mode): queue a batch of writes, then process each `request.succeeded` or `request.failed` event as it arrives.

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification, so you can verify them with existing open-source libraries in most languages.

## 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](https://www.desktopaccountingapi.com/docs/connect/connection-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

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

```sh
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.

## Payload

Payloads are small. They identify what happened; fetch details through the API.

```json
{
  "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](https://www.desktopaccountingapi.com/docs/guides/request-lifecycle/#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

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.

**TypeScript**

```ts
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();
});
```

**Python**

```python
import os
from flask import Flask, request
from 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 "", 204
```

**Any language**

Any Standard Webhooks library works. For example, with the `standardwebhooks` package:

```python
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

- 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`.

## 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

- **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.
