Skip to main content
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 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.
  • 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.
  • 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.
IPNotes
100.29.146.21Primary.
100.28.184.179Secondary.
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.
PropertyBehavior
DeliveryPOST 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.
SigningDualEntry signs every request. Header Dualentry-Webhook-Signature-V1 is HMAC_SHA256(secret, "<timestamp>.<raw_body>"), hex-encoded.
GuaranteeAt-least-once. The same event can arrive more than once, so dedupe on event_id.
OrderingEvents are not ordered. Two updates to the same record can arrive in either sequence.
RetriesNon-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-disableAn endpoint failing continuously for 5 days is automatically disabled; re-enable it via PATCH after fixing the receiver.
Delivery logDualEntry 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.
1

List the topics you can subscribe to

The subscribable topic catalog is an allowlist of business records with created, updated, and deleted lifecycle actions.
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.
2

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.
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_...", ...}
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.

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 below for the algorithm.
  • Inspect the delivery log. Every attempt (successful or failed) is recorded. Poll the delivery log by webhook UUID:
    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.
{
  "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:
HeaderMeaning
Dualentry-Webhook-UuidThe webhook UUID that produced this delivery.
Dualentry-Webhook-Request-TimestampUnix seconds when the payload was signed. Part of the signed string, which is what protects against replay.
Dualentry-Webhook-Signature-V1Hex-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.
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.
// 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.
MethodPathPurpose
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.

Retry, disable, and reactivation policy

WebhookDelivery owns the retry state machine. Your endpoint’s response determines DualEntry’s next action:
Response from your endpointWhat DualEntry does
Any 2xxMarks DELIVERED, terminal.
400, 401, 403, 404, 405, 410, 422Marks 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 errorReschedules with exponential backoff. Gives up after MAX_ATTEMPTS.
Webhook is inactive at delivery timeSkips 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:
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.
RuleWhy 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.
SymptomCauseResolution
422 Unknown topic on registerTopic name not in the allowlist, or typo.GET /public/v2/webhooks/topics/ and copy the exact name.
422 on register with a URL errorURL is HTTP, loopback, private IP, or otherwise blocked by SSRF validation.Use a publicly reachable HTTPS URL.
Deliveries never arrive; log shows connection timeoutsFirewall / WAF is dropping DualEntry’s egress IPs.Allowlist both source IPs at every layer (cloud security group, WAF, reverse proxy).
Signature never verifiesBody 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 4xxYour 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-disabled5 days of continuous failure.Fix the receiver, then PATCH is_active=true; existing failed events are not replayed.
Duplicate events processedMissing event_id dedupe.Dedupe on event_id in a persistent store before doing any downstream work.
Events arrive out of orderDeliveries 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 secretSecret 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

Last modified on July 7, 2026