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
INTEGRATIONpermission (create,view,edit,archiveas 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.| IP | Notes |
|---|---|
100.29.146.21 | Primary. |
100.28.184.179 | Secondary. |
How DualEntry webhooks work
DualEntry webhooks deliver events via HTTPSPOST 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. |
Register a webhook
All calls authenticate with an organization API key via theX-API-KEY header.
List the topics you can subscribe to
The subscribable topic catalog is an allowlist of business records with Topic names follow the pattern
created, updated, and deleted lifecycle actions.<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.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
POSTwhose 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:
Each entry shows
event_id,topic,status,attempts,response_status,last_error,delivered_at, andcreated_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 ontopic without special-casing per resource.
event_idis a deterministic UUID. DualEntry generates the sameevent_idfor the same business event across retries, replays, and process restarts, so use it as your idempotency key.objectis 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_versionpins the envelope schema for that delivery. Rows in flight during version updates continue to ship with the version they were created under, so key offapi_versionif 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:- Recompute the HMAC over the exact string
"<timestamp>.<raw_body>"using your storedsecret. Use the raw bytes of the request body. Do not parse JSON and re-serialize, or whitespace and key order will change the hash. - Constant-time compare the recomputed digest against the
Dualentry-Webhook-Signature-V1header value. A regular string comparison reveals timing information about prefix matches, enabling timing attacks. - Reject stale timestamps. If
abs(now - timestamp) > 300seconds, reject the request. This defeats attackers who capture an old signed request and replay it later.
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 onevent_id, and returns 200 fast. Load WEBHOOK_SECRET from your secrets store; never hardcode it.
- Signature verification blocks anything that isn’t signed with your secret.
- The 5-minute replay window blocks captured-and-replayed old requests.
- The
event_iddedupe handles at-least-once delivery. - A fast
2xxkeeps 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.
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, toggleis_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). |
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". |
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 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 hasobject.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 onevent_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.
- Read the Public API v2 webhooks reference for exact request and response schemas.
- Compare webhook-driven flows to polling in Building a custom integration.
- Return to Integrations for the full list of prebuilt connectors.
