Guide to INGENIOUS.BUILD Webhooks: Features and Best Practices
INGENIOUS.BUILD webhooks move approved financial records out of a construction project and into your accounting system. When someone on a project clicks Sync to Accounting on a budget, budget change, contract, contract change or invoice, INGENIOUS.BUILD posts the record to a URL you control. Your middleware, whether that's n8n, Zapier, Jitterbit, Retool or your own service, transforms it and calls your ERP or accounting API.
This guide covers how the Custom Accounting Integration sends webhooks, the five payloads and their accounting mapping fields, how to authenticate requests with a header you add, and the practices that make an INGENIOUS.BUILD receiver dependable.
What are INGENIOUS.BUILD webhooks?
The Custom Accounting Integration is INGENIOUS.BUILD's system-agnostic way to connect to accounting. Rather than a built-in connector for each ERP, it sends a standard JSON payload to a webhook URL and leaves the translation to your middleware. You configure one URL per object type, and leave a URL blank for any object you don't sync.
Webhooks are push-only and user-driven. Nothing is sent on a schedule or on every edit: a request goes out at the moment a user clicks Sync to Accounting on a record that meets your workspace's prerequisites. Data flowing the other way, such as vendors, cost codes, GL accounts and projects, goes into INGENIOUS.BUILD through the Public API or CSV import.
INGENIOUS.BUILD does not sign webhook requests. The setup guide asks you to add any authorization headers your endpoint expects next to each webhook URL, for example Authorization: Bearer your-token-here. Your receiver compares that header with the value it stored.
INGENIOUS.BUILD webhook features
| Feature | Details |
|---|---|
| Configuration | Company Settings > Integrations Hub > Custom Accounting Integration, workspace admin access |
| Endpoints | One URL per object type: Budgets, Budget Changes, Contracts, Contract Changes, Invoices |
| Trigger | A user clicks Sync to Accounting on an eligible record |
| Method and format | POST, JSON body |
| Envelope | { "name", "subject", "data" } |
| Authentication | Headers you add next to each URL |
| Signature and timestamp | None |
| Success indicator | INGENIOUS.BUILD shows "Successfully Synced" |
| Validation | Optional Mapping Requirements block the sync until accounting objects are mapped |
| Retries, timeout and source IPs | Not documented |
Common events
name | subject | Sent when |
|---|---|---|
budget_ | budget | Sync to Accounting is clicked in the Budget module |
budget_ | budget_change | ... in the Budget Change module |
contract_ | contract | ... in the Contract module |
contract_ | contract_change | ... in the Contract Change module |
invoice_ | invoice | ... in the Invoice module |
Here is INGENIOUS.BUILD's invoice example, trimmed to one line item:
{
"name": "invoice_exported_to_accounting",
"subject": "invoice",
"data": {
"id": "507f1f77bcf86cd799439012",
"accounting_project_id": "507f1f77bcf86cd799439015",
"accounting_project_custom_id": "PROJ-001",
"accounting_company_id": "507f1f77bcf86cd799439016",
"accounting_company_custom_id": "VEND-001",
"vendor_id": "507f1f77bcf86cd799439018",
"project_id": "507f1f77bcf86cd799439020",
"contract_id": "507f1f77bcf86cd799439021",
"invoice_number": "INV-123",
"custom_id": "INV-UNIQUE-123",
"gross_value": "1000.00",
"net_value": "950.00",
"status": "draft",
"retention_percent": "5.00",
"invoice_date": "2025-11-12",
"items": [
{
"id": "507f1f77bcf86cd799439010",
"gross_value": "1000.00",
"unit_value": "100.00",
"qty": 10,
"accounting_cost_code_custom_id": "CC-001",
"accounting_entity_custom_id": "AE-001",
"retention_gross_value": "50.00"
}
]
}
}
Monetary values are strings with two decimal places, so parse them with a decimal type rather than a float.
Every payload carries INGENIOUS.BUILD's own IDs, such as id, project_id and vendor_id, alongside accounting mapping fields: accounting_project_id and accounting_, and the same pairs for company, cost code, cost type, entity and business unit. How those are filled depends on your Mapping Requirements:
| Scenario | accounting_*_id | accounting_*_custom_id |
|---|---|---|
| Mapping not required | Empty | Empty |
Mapping required, no custom_id set | INGENIOUS internal ID | Empty |
Mapping required, custom_id set | Populated | Populated |
The last row is what you want: when every accounting object in INGENIOUS.BUILD has a custom_id matching your accounting system, your middleware can match records directly without calling back to the Public API.
Inspect INGENIOUS.BUILD webhooks as they arrive. INGENIOUS.BUILD suggests pointing a webhook URL at a request bin for your first test. A Hookdeck Inspect URL does that, with no account or setup required, and lets you replay the payload against your middleware later.
Setting up INGENIOUS.BUILD webhooks
Choose a header name and a long random value first, for example Authorization with Bearer followed by the output of openssl rand -hex 32.
- Log in to INGENIOUS.BUILD as a workspace admin and go to Company Settings > Integrations Hub.
- Select Custom Accounting Integration and click Start Integration.
- In Mapping Requirements, choose which accounting objects must be mapped before a record can sync. Leaving them all unchecked is fine while testing.
- For each object type you plan to sync, paste the webhook URL and add your authorization header. Leave the other URLs blank.
- Load your accounting objects into INGENIOUS.BUILD through the Public API or CSV import, with a
custom_idon each. - Take a small invoice through approval, map any required fields, and click Sync to Accounting. Look for the "Successfully Synced" message, and check what your endpoint received.
With Hookdeck. Create a Hookdeck source with the INGENIOUS.BUILD type and use its URL for each object type. Enter the header name in API Key Header and the value in API Key, exactly as you set them on the integration, including any Bearer prefix. The source compares the whole value and accepts POST. Add the same header to each of the five URLs, since they're configured separately.
Securing INGENIOUS.BUILD webhooks
Look up the header you chose, case-insensitively, and compare the whole value with the one you stored, in constant time. Don't strip a Bearer prefix: compare exactly what you typed into the integration.
const crypto = require('crypto');
const express = require('express');
const HEADER = (process.env.INGENIOUS_BUILD_WEBHOOK_HEADER || 'Authorization').toLowerCase();
const EXPECTED = process.env.INGENIOUS_BUILD_WEBHOOK_SECRET; // e.g. "Bearer 7c1e..."
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on unequal lengths, so check first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/webhooks/ingenious-build', express.raw({ type: '*/*' }), (req, res) => {
const received = req.headers[HEADER]; // Node lower-cases header names
if (!EXPECTED || typeof received !== 'string' || !safeEqual(received, EXPECTED)) {
return res.sendStatus(401);
}
let payload;
try {
payload = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
switch (payload.name) {
case 'invoice_exported_to_accounting':
// Create or update the invoice in your accounting system.
break;
default:
// budget, budget change, contract and contract change exports
break;
}
res.sendStatus(200);
});
The same check in Python:
import hmac
import os
def verify_ingenious_build_request(headers) -> bool:
secret = os.environ.get("INGENIOUS_BUILD_WEBHOOK_SECRET")
if not secret:
return False # never accept when unconfigured
name = os.environ.get("INGENIOUS_BUILD_WEBHOOK_HEADER", "Authorization")
received = headers.get(name) # case-insensitive in Starlette and Flask
if not received:
return False
return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))
A static header proves the sender knows the secret. It does not cover the body, and there is no timestamp, so a captured request can be replayed and still pass. For financial records that matters: serve the endpoint over HTTPS only, use a long random value, and make posting idempotent so a replayed invoice isn't booked twice. Rotate the value on all five URLs and in your environment together if it might have leaked.
Make INGENIOUS.BUILD webhooks production-ready. Hookdeck Event Gateway checks your header on every request, keeps every synced record durably, and retries delivery to your middleware if your accounting system is unavailable.
INGENIOUS.BUILD webhook limitations and pain points
No documented retries
The Problem: If your middleware is down when a user clicks Sync to Accounting, you can't rely on INGENIOUS.BUILD to try again.
Why It Happens: The documentation covers the sync button and the "Successfully Synced" message, but says nothing about retries or timeouts. The trigger is a user action, so recovery depends on someone noticing and clicking again.
Workarounds:
- Return a success response as soon as the header checks out, and post to your accounting system afterwards.
- Reconcile records in an approved state through the Public API, so anything that never arrived is caught.
How Hookdeck Can Help: Hookdeck accepts and stores each request as soon as it arrives, then delivers to your middleware with retries you configure. If your accounting API rejects a record, fix the mapping and replay the event instead of asking a project manager to sync again.
Repeated syncs of the same record
The Problem: If the same record is synced more than once, a middleware that always creates records books a duplicate invoice or contract.
Why It Happens: Every Sync to Accounting click sends a request, and the payload has no delivery ID.
Workarounds:
- Upsert by the record's
idorcustom_idin your accounting system rather than always creating. - Keep a record of which INGENIOUS IDs you've posted, and compare values before updating.
How Hookdeck Can Help: Hookdeck deduplication can drop identical repeats within a window before they reach your middleware.
Mapping gaps in the payload
The Problem: Your middleware receives an invoice with empty accounting_*_custom_id fields and can't match the vendor or cost code.
Why It Happens: Accounting fields are empty unless the mapping is required, and the custom IDs are empty unless the accounting object was created with a custom_id.
Workarounds:
- Turn on the Mapping Requirements your accounting system needs, so INGENIOUS.BUILD blocks the sync until they're filled.
- Set
custom_idon every accounting object you load, or call the Public API to look up the record byaccounting_*_id.
How Hookdeck Can Help: Hookdeck stores every payload, so a record your middleware couldn't match can be replayed once the mapping is fixed.
Five URLs to keep in step
The Problem: One object type keeps failing after a rotation or a URL change, because only four of the five settings were updated.
Why It Happens: Each object type has its own URL and its own headers.
Workarounds:
- Use the same header name and value on every URL, and keep a checklist for changes.
- Point all five at one receiver and route on
name.
How Hookdeck Can Help: Use one Hookdeck source URL for all five object types, then filters on name to send each record type to the right destination.
Best practices
Route on name, not the URL
Even if you use separate URLs, the name field is always present and unambiguous. Check it before processing.
Set custom_id everywhere
Give every accounting object you load into INGENIOUS.BUILD a custom_id that matches your accounting system. It appears in every payload and saves an API call per record.
Treat money as decimal strings
gross_value, net_value, unit_value and the retention fields arrive as strings such as "1000.00". Parse them with a decimal library and never round through floating point.
Make posting idempotent
Use the INGENIOUS record id as the key in your accounting system, so a repeated sync updates rather than duplicates. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Accounting APIs can be slow. Verify the header, store the payload, respond, then call your ERP from a background job. See why to process webhooks asynchronously.
Conclusion
INGENIOUS.BUILD sends a { name, subject, data } JSON POST each time a user syncs a budget, budget change, contract, contract change or invoice to accounting, authenticated only by a header you add to each URL. Compare that header whole in constant time over HTTPS, route on name, set custom_id on your accounting objects, and make posting idempotent so repeated syncs are harmless.
Hookdeck Event Gateway checks the header, keeps every synced record, and retries and replays delivery to your middleware, which fills the gap left by INGENIOUS.BUILD's undocumented retry behaviour.
Get started with Hookdeck for free and handle INGENIOUS.BUILD webhooks reliably in minutes.