> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dualentry.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks: Notify external systems on record changes

> Register HTTPS endpoints that receive signed event payloads when DualEntry records change, with HMAC verification, retries, and delivery logs.

Webhooks push a signed JSON payload to a URL you control every time a subscribed record changes in DualEntry: a new invoice is created, a bill is updated, a customer is deleted. Instead of polling the API on a schedule, your downstream system reacts to events within seconds of them happening in DualEntry. This enables real-time syncs into a data warehouse, alerting workflows, or companion apps.

This page walks you through the full lifecycle: choose the events you care about, register the endpoint that will receive them, verify each delivery is genuinely from DualEntry, and monitor what got through. If you are building the receiver yourself, the [Public API v2 reference for webhooks](../../developers/api/resources-v2/webhooks/list-webhooks) documents every endpoint field-by-field; this page is the operational companion.

## Prerequisites

Confirm the following before registering a webhook:

* An HTTPS endpoint you can deploy code to. It must be publicly reachable from DualEntry's egress and terminate TLS. Loopback and private IPs are rejected on registration for SSRF protection. If your infrastructure allowlists inbound traffic, permit DualEntry's [source IPs](#source-ips-to-allowlist).
* A DualEntry organization-scoped Public API key with the `INTEGRATION` permission (`create`, `view`, `edit`, `archive` as appropriate). Create the key under **Organization Management → your organization → API Keys**. See [Authentication](../../developers/guides/authentication).
* A place to store the signing secret returned by the register call. Treat it like a password. It is shown **once** and can never be retrieved again.
* Agreement with engineering on which topics you need. DualEntry maintains an explicit allowlist of topics for business records (customers, invoices, bills, journal entries, and similar). Not every audited change is exposed.

## Source IPs to allowlist

DualEntry sends webhook events from the production IPs listed in the table below. DualEntry sends events from either IP, so allow **both** at your firewall, WAF, or reverse proxy. Do not pin traffic to just one address.

| IP               | Notes      |
| ---------------- | ---------- |
| `100.29.146.21`  | Primary.   |
| `100.28.184.179` | Secondary. |

Use this list in addition to (not as a replacement for) HMAC verification. IP allowlisting keeps casual traffic off the receiver; the signature check is what proves a request actually came from DualEntry.

## How DualEntry webhooks work

DualEntry webhooks deliver events via HTTPS `POST` with these guarantees: signed payloads, at-least-once delivery, unordered arrival, and status-aware retries. Read this once; every subsequent decision in this doc depends on it.

| Property     | Behavior                                                                                                                                                                        |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Delivery     | `POST` to your URL with a JSON body (Stripe-style envelope). Fires only after the originating write commits, so rolled-back changes never emit a webhook.                       |
| Signing      | DualEntry signs every request. Header `Dualentry-Webhook-Signature-V1` is `HMAC_SHA256(secret, "<timestamp>.<raw_body>")`, hex-encoded.                                         |
| Guarantee    | At-least-once. The same event can arrive more than once, so dedupe on `event_id`.                                                                                               |
| Ordering     | Events are **not** ordered. Two updates to the same record can arrive in either sequence.                                                                                       |
| Retries      | Non-2xx or timeout retries with exponential backoff. `400/401/403/404/405/410/422` stop retries immediately. Treat those 4xx codes as "your endpoint rejected this on purpose". |
| Auto-disable | An endpoint failing continuously for 5 days is automatically disabled; re-enable it via `PATCH` after fixing the receiver.                                                      |
| Delivery log | DualEntry persists every attempt (status, response code, error, timestamp) and makes it readable via the API for at least the retention window.                                 |

Your receiver must be idempotent because the system guarantees at-least-once delivery and does not order events.

## Register a webhook

All calls authenticate with an organization API key via the `X-API-KEY` header.

<Steps>
  <Step title="List the topics you can subscribe to">
    The subscribable topic catalog is an allowlist of business records with `created`, `updated`, and `deleted` lifecycle actions.

    ```bash theme={null}
    curl -s -H "X-API-KEY: $KEY" https://api.dualentry.com/public/v2/webhooks/topics/
    # -> [{"name": "core.customer/created"}, {"name": "core.invoice/created"}, ...]
    ```

    Topic names follow the pattern `<app>.<Model>/<action>`, for example `core.invoice/updated`. Anything not returned by this endpoint cannot be subscribed to and will be rejected with `422` on registration.
  </Step>

  <Step title="Register your endpoint">
    Specify a public HTTPS URL and select the topics you want to subscribe to. The response includes a signing `secret` **exactly once**, so copy it into your secrets manager immediately.

    ```bash theme={null}
    curl -s -H "X-API-KEY: $KEY" -H 'Content-Type: application/json' \
      -d '{"url":"https://your-app.example.com/dualentry/webhook",
           "topics":["core.customer/created","core.invoice/created","core.invoice/updated"]}' \
      https://api.dualentry.com/public/v2/webhooks/
    # 201 -> {"uuid":"...", "url":"...", "topics":[...], "secret":"whsec_...", ...}
    ```

    <Warning>
      The `secret` is only returned by this response. It is never included on `GET` calls. If it is lost, the only recovery is to delete the webhook and create a new one with a new URL registration. There is no rotate endpoint yet.
    </Warning>
  </Step>
</Steps>

## Verify and inspect deliveries

After registration, confirm the wiring is real end-to-end and learn where to look when something goes wrong.

* **Verify a live delivery.** Trigger any subscribed record change (create a customer, edit an invoice) and confirm your endpoint received a `POST` whose signature verifies against the stored secret. See [Verify the HMAC signature](#verify-the-hmac-signature) below for the algorithm.
* **Inspect the delivery log.** Every attempt (successful or failed) is recorded. Poll the delivery log by webhook UUID:

  ```bash theme={null}
  curl -s -H "X-API-KEY: $KEY" \
    https://api.dualentry.com/public/v2/webhooks/<uuid>/deliveries/
  ```

  Each entry shows `event_id`, `topic`, `status`, `attempts`, `response_status`, `last_error`, `delivered_at`, and `created_at`. This is the single source of truth when a downstream system claims it "didn't get" an event.

## Payload envelope

Every request body follows the same shape, so your receiver can dispatch on `topic` without special-casing per resource.

```json theme={null}
{
  "event_id": "b8f4c2a0-1e21-5c9a-bb27-7ac4e6f8b1d2",
  "api_version": "2026-06-27",
  "topic": "core.invoice/created",
  "object_type": "core.Invoice",
  "object": { "id": 4821 },
  "webhook_uuid": "...",
  "created_at": "2026-07-07T14:22:04.123456Z"
}
```

* `event_id` is a deterministic UUID. DualEntry generates the same `event_id` for the same business event across retries, replays, and process restarts, so use it as your idempotency key.
* `object` is a stable reference (`{"id": ...}`), not the full record. Fetch the object from the corresponding `/public/v2/<resource>/{id}/` endpoint if you need current field values. This keeps the payload compact and avoids shipping stale field snapshots.
* `api_version` pins the envelope schema for that delivery. Rows in flight during version updates continue to ship with the version they were created under, so key off `api_version` if you support more than one.

## Verify the HMAC signature

This section covers what to check on every incoming request so you can trust the payload. Read the headers, run three verification steps, and only then dispatch on the event.

### Authentication headers

Every delivery carries three headers you use to authenticate the request:

| Header                                | Meaning                                                                                                     |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `Dualentry-Webhook-Uuid`              | The webhook UUID that produced this delivery.                                                               |
| `Dualentry-Webhook-Request-Timestamp` | Unix seconds when the payload was signed. Part of the signed string, which is what protects against replay. |
| `Dualentry-Webhook-Signature-V1`      | Hex-encoded `HMAC_SHA256(secret, "<timestamp>.<raw_body>")`.                                                |

### Verification steps

Verification is three things, in this order, and none of them is optional:

1. **Recompute the HMAC** over the exact string `"<timestamp>.<raw_body>"` using your stored `secret`. Use the **raw bytes** of the request body. Do **not** parse JSON and re-serialize, or whitespace and key order will change the hash.
2. **Constant-time compare** the recomputed digest against the `Dualentry-Webhook-Signature-V1` header value. A regular string comparison reveals timing information about prefix matches, enabling timing attacks.
3. **Reject stale timestamps.** If `abs(now - timestamp) > 300` seconds, reject the request. This defeats attackers who capture an old signed request and replay it later.

Only after all three checks pass do you treat the payload as authentic and dispatch on it.

## Reference receiver implementation

This section provides ready-to-adapt code for the three verification steps above. The Python snippet is a full production-shaped receiver; the Node.js snippet is a signature-verification helper you can wire into any Express handler.

### Python / Flask

The snippet below is a minimal, production-shaped receiver. It verifies the signature, rejects replays, dedupes on `event_id`, and returns `200` fast. Load `WEBHOOK_SECRET` from your secrets store; never hardcode it.

```python theme={null}
import hashlib
import hmac
import os
import time
from flask import Flask, request

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["DUALENTRY_WEBHOOK_SECRET"]
seen: set[str] = set()  # replace with Redis / a DB table in production


def is_valid_signature(timestamp: str, body: bytes, signature: str) -> bool:
    """Recompute the HMAC and constant-time compare against the header."""
    signed = f"{timestamp}.".encode() + body
    expected = hmac.new(
        WEBHOOK_SECRET.encode(), signed, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/dualentry/webhook")
def dualentry_webhook():
    body = request.get_data()  # raw bytes - do NOT reserialize
    ts = request.headers.get("Dualentry-Webhook-Request-Timestamp", "")
    sig = request.headers.get("Dualentry-Webhook-Signature-V1", "")

    # 1. Signature check (forged / tampered payloads).
    if not ts or not sig or not is_valid_signature(ts, body, sig):
        return "", 400  # 4xx stops retries

    # 2. Replay window (older than 5 minutes -> reject).
    try:
        if abs(time.time() - int(ts)) > 300:
            return "", 400
    except ValueError:
        return "", 400

    event = request.get_json()

    # 3. Idempotency (at-least-once delivery).
    if event["event_id"] in seen:
        return "", 200
    seen.add(event["event_id"])

    # 4. Dispatch on topic. Return 2xx fast; do heavy work in a queue.
    topic = event["topic"]
    obj_id = event["object"]["id"]
    print(f"received {topic} for id={obj_id}")

    return "", 200
```

The four checks map directly to the delivery semantics:

* Signature verification blocks anything that isn't signed with your secret.
* The 5-minute replay window blocks captured-and-replayed old requests.
* The `event_id` dedupe handles at-least-once delivery.
* A fast `2xx` keeps you out of the retry loop; do the actual work asynchronously.

### Node.js / Express

The algorithm is the same everywhere: HMAC-SHA256 over `"<timestamp>.<raw_body>"`, hex-encoded, constant-time compared.

```javascript theme={null}
// Node.js (Express) - body-parser must expose the raw body.
const crypto = require("crypto");

function isValidSignature(secret, timestamp, rawBody, signature) {
  const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Manage webhooks over the API

Once a webhook is live, you rarely re-register it. Instead, you list what's active, patch the URL when your receiver moves, toggle `is_active` off during maintenance windows, and pull the delivery log when downstream reports a missing event. The full lifecycle runs on the same `/public/v2/webhooks/` router. All routes are org-scoped by the API key and gated by the `INTEGRATION` permission.

| Method          | Path                                     | Purpose                                                                                    |
| --------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------ |
| `POST`          | `/public/v2/webhooks/`                   | Register an endpoint. Returns the signing `secret` **once**.                               |
| `GET`           | `/public/v2/webhooks/`                   | Paginated list. Never includes `secret`. Filterable by `is_active`.                        |
| `GET`           | `/public/v2/webhooks/topics/`            | Subscribable topic catalog.                                                                |
| `GET`           | `/public/v2/webhooks/{uuid}/`            | Fetch one webhook.                                                                         |
| `PUT` / `PATCH` | `/public/v2/webhooks/{uuid}/`            | Change URL, replace topics, toggle `is_active`. Re-enabling clears the auto-disable state. |
| `DELETE`        | `/public/v2/webhooks/{uuid}/`            | Delete a webhook (cascades its delivery rows).                                             |
| `GET`           | `/public/v2/webhooks/{uuid}/deliveries/` | Paginated delivery attempts (event log).                                                   |

Full request and response schemas are in the [API reference](../../developers/api/resources-v2/webhooks/list-webhooks).

## Retry, disable, and reactivation policy

`WebhookDelivery` owns the retry state machine. Your endpoint's response determines DualEntry's next action:

| Response from your endpoint                                       | What DualEntry does                                                                                                   |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Any `2xx`                                                         | Marks `DELIVERED`, terminal.                                                                                          |
| `400`, `401`, `403`, `404`, `405`, `410`, `422`                   | Marks `FAILED` on the first attempt. **No further retries.** Treat these as "your endpoint rejected this on purpose." |
| `429` with a valid `Retry-After` (seconds or HTTP-date)           | Reschedules for exactly that time. Ignores the max-attempts budget so we pace instead of giving up.                   |
| `429` without `Retry-After`, any `5xx`, timeout, connection error | Reschedules with exponential backoff. Gives up after `MAX_ATTEMPTS`.                                                  |
| Webhook is inactive at delivery time                              | Skips the `POST` entirely and marks the row `FAILED` with `last_error="webhook_disabled"`.                            |

If an endpoint fails continuously for 5 days it is automatically disabled. Reactivate it after fixing the receiver:

```bash theme={null}
curl -s -H "X-API-KEY: $KEY" -H 'Content-Type: application/json' \
  -X PATCH -d '{"is_active": true}' \
  https://api.dualentry.com/public/v2/webhooks/<uuid>/
```

Reactivation clears the disable state. Deliveries fired **after** you reactivate flow normally. Events that failed while the webhook was disabled are not automatically re-sent; if you need to catch up, backfill from the corresponding resource endpoints (for example `GET /public/v2/invoices/`).

## Operate safely in production

The rules below cover most of the incidents this system creates. Treat them as a pre-launch checklist for the receiver.

| Rule                                                                     | Why it matters                                                                                                                                                                                    |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Store the secret in a real secrets manager, not in code or config files. | If the secret leaks, an attacker can forge signed payloads. Rotate by re-registering the webhook (which mints a new secret).                                                                      |
| Return `2xx` fast, then do work in a queue.                              | Every second your handler spends on business logic pushes closer to a timeout, and a timeout burns a retry.                                                                                       |
| Dedupe on `event_id` in a persistent store.                              | At-least-once delivery produces duplicates on retries, redeploys, and sweeper races. An in-memory set is fine for a demo, not production.                                                         |
| Do not rely on event order.                                              | Two updates to the same invoice can arrive in either sequence. When you need "latest", fetch the record and use its `last_modified_at`.                                                           |
| Return `4xx` on purpose.                                                 | Returning `400` on a payload you can't process stops retries. Correct for permanent problems, wrong for transient ones (use `5xx` or a timeout for those).                                        |
| Watch the delivery log.                                                  | Periodically list `/webhooks/{uuid}/deliveries/` and alert on rising `attempts` or `response_status` drifting away from `200`. Auto-disable at 5 days is a safety net, not a monitoring strategy. |

## Troubleshoot common failures

Common webhook issues and their solutions.

| Symptom                                                | Cause                                                                                                   | Resolution                                                                                                                                       |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `422 Unknown topic` on register                        | Topic name not in the allowlist, or typo.                                                               | `GET /public/v2/webhooks/topics/` and copy the exact name.                                                                                       |
| `422` on register with a URL error                     | URL is HTTP, loopback, private IP, or otherwise blocked by SSRF validation.                             | Use a publicly reachable HTTPS URL.                                                                                                              |
| Deliveries never arrive; log shows connection timeouts | Firewall / WAF is dropping DualEntry's egress IPs.                                                      | Allowlist both [source IPs](#source-ips-to-allowlist) at every layer (cloud security group, WAF, reverse proxy).                                 |
| Signature never verifies                               | Body was re-serialized (JSON re-encoded), wrong secret, or hex vs. base64 confusion.                    | Use the raw request body bytes; compare hex-encoded digests; confirm the stored secret matches the `whsec_...` value from the register response. |
| Every delivery is `FAILED` with the same 4xx           | Your endpoint is returning one of `400/401/403/404/405/410/422`, and DualEntry stops retrying on those. | Fix the receiver, then either wait for the next event or re-trigger the source record.                                                           |
| Webhook was auto-disabled                              | 5 days of continuous failure.                                                                           | Fix the receiver, then `PATCH is_active=true`; existing failed events are not replayed.                                                          |
| Duplicate events processed                             | Missing `event_id` dedupe.                                                                              | Dedupe on `event_id` in a persistent store before doing any downstream work.                                                                     |
| Events arrive out of order                             | Deliveries are unordered by design.                                                                     | On any "update", refetch the record by ID and use `last_modified_at` as the source of truth.                                                     |
| Lost the signing secret                                | Secret is only returned once.                                                                           | Delete the webhook and register a new one; there is no rotate endpoint yet.                                                                      |

## FAQ

Common questions about webhook implementation and behavior.

### Are webhooks the same as the Stripe / Brex / Plaid webhooks I see in DualEntry?

No. Those are **inbound** webhooks that DualEntry receives from third parties (payment processors, banking) to update your books. This page describes **outbound** webhooks that DualEntry sends to systems you own when your records change. They share the word "webhook" and nothing else.

### Do webhooks include the full record body?

No. The payload has `object.id` and enough envelope to route on (`topic`, `object_type`). Fetch the full record from the matching `/public/v2/<resource>/{id}/` endpoint when you need current field values. This keeps payloads small, avoids shipping stale snapshots, and forces receivers to work against the API's canonical representation.

### Can I subscribe to every change in the system?

No. DualEntry maintains an explicit allowlist of business records as the topic catalog. Internal bookkeeping, session events, and non-lifecycle actions are never exposed. If you need a record that isn't in the catalog, ask engineering to add it; it is a small edit on their side.

### Can I use webhooks and polling together?

Yes, and for critical flows you probably should. Webhooks are the fast path; a periodic reconciliation job that lists the same resource on a longer schedule catches anything the webhook missed (an endpoint outage, an accidentally deleted webhook). Idempotency on `event_id` and record ID keeps them from stepping on each other.

## Next steps

* Register your first webhook against a non-production DualEntry organization using the [Register endpoint](../../developers/api/resources-v2/webhooks/create-webhook).
* Read the [Public API v2 webhooks reference](../../developers/api/resources-v2/webhooks/list-webhooks) for exact request and response schemas.
* Compare webhook-driven flows to polling in [Building a custom integration](../../developers/guides/building-a-custom-integration).
* Return to [Integrations](./index) for the full list of prebuilt connectors.
