Webhooks & events
Signed webhooks with retries and replay, plus an ordered event feed you can page with a cursor for incremental sync.
View as MarkdownTruTec tells your system about changes in two ways:
- Webhooks push a signed JSON
POSTto your URL when something happens. - The event feed (
GET /v1/events) is an ordered, append-only log you page with a cursor.
Use both. Webhooks give you low latency. The event feed is the source of truth for incremental sync: if your receiver was down, page the feed from your last cursor and you have everything.
Webhooks and the event feed are per workspace. Partner keys must name the workspace with firm_id (in the query string, or in the JSON body for POST/PATCH on /v1/webhooks). See Partner API.
Event types#
There are two families of events.
Business events#
Each one is recorded in the same database transaction as the change itself. Its UTC timestamp is never rewritten. These events appear in the event feed and can be delivered by webhook.
| Event | Fires when | entity.type |
|---|---|---|
job_won |
A job enters a pipeline stage whose type is won | job |
job_lost |
An open job enters a lost or excluded stage | job |
job_cancelled |
A won job moves to a lost or excluded stage | job |
job_completed |
A job enters the workspace's completed stage | job |
job_scheduled |
A crew work order gets a date, or its date moves | work_order |
work_order_completed |
A crew work order is marked complete | work_order |
estimate_created |
An estimate (or change-order estimate) is created | estimate |
estimate_issued |
Version 1 of an estimate is issued | estimate_version |
estimate_revised |
Version 2 or later of an estimate is issued | estimate_version |
estimate_approved |
An estimate is awarded | estimate |
estimate_declined |
An estimate is marked lost | estimate |
change_order_approved |
A change order is approved | change_order |
invoice_created |
An invoice is created | invoice |
payment_received |
An invoice becomes paid | invoice |
The feed also contains two raw job events, job_created and job_status_changed. The job_won, job_lost, job_cancelled and job_completed events are derived from job_status_changed. They keep its occurred_at, but they get a higher feed id because they are written a moment later.
Job and proposal events#
These are sent only by webhook. They are not in the event feed.
| Event | Fires when |
|---|---|
job_created |
A job is created by any path: the app, the mobile app, the API, an import |
job_updated |
A job's details change |
job_status_changed |
A job moves to a new status |
proposal_sent |
A proposal is sent to the customer |
proposal_viewed |
The customer opens the proposal for the first time |
proposal_accepted |
The customer accepts |
proposal_declined |
The customer declines |
A status change made through the API fires job_status_changed instead of job_updated, not both.
Subscription names#
When you create a subscription, event_type can be:
- one business event, by its underscore name (
estimate_revised) or dotted alias (estimate.revised); all: every business event in the first table;- one job or proposal event (
job_created,proposal_accepted, ...); *: every job and proposal event in the second table.
all and * are separate. all never sends job or proposal events, and * never sends business events. To get everything, create two subscriptions.
If you leave event_type out, the subscription gets job_status_changed only.
Payloads#
Business event payload#
{
"event_type": "estimate_revised",
"event_id": "8f7af1dc-3a9a-4b9c-99ff-1a7d85a457c8",
"api_version": "2026-09",
"occurred_at": "2026-10-12T15:04:11Z",
"created_at": "2026-10-12T15:04:11Z",
"firm_id": 311,
"entity": {"type": "estimate_version", "id": 812},
"data": {"estimate_id": 4410, "version": 2, "issued_by_user_id": 1204},
"job": {"id": 4812, "name": "Northgate Plaza", "status": "estimate", "...": "..."},
"estimate": {"id": 4410, "version": 2, "totals": {"price": 48250.0, "...": "..."}, "areas": ["..."]}
}
event_idis a UUID. It is the same in the feed, in every retry and in a replay. Dedupe on it.jobis included when the event belongs to a job. It has the same shape as the job object in the job and proposal events below.estimateis included onestimate_*events. It is the structured estimate with line items, the same asGET /v1/estimates/{id}(with?version=Nfor issued and revised versions). See Estimates & proposals.jobandestimateare built when the delivery is queued, not when the change happened. A later replay shows the current record.
data by event type:
| Event | data fields |
|---|---|
job_created (feed only) |
status |
job_status_changed (feed only) |
from_status, to_status |
job_won, job_lost, job_cancelled, job_completed |
from_status, to_status, source_event_id (the event_id of the status change) |
job_scheduled |
work_order_id, scheduled_date (YYYY-MM-DD), previous_scheduled_date (null on first schedule), crew_id |
work_order_completed |
work_order_id, completed_at |
estimate_created |
estimate_id, status, is_change_order, parent_estimate_id |
estimate_issued, estimate_revised |
estimate_id, version, issued_by_user_id |
estimate_approved |
estimate_id, status, is_change_order, parent_estimate_id, award_snapshot_id |
estimate_declined |
estimate_id, status, is_change_order, parent_estimate_id, from_status |
change_order_approved |
change_order_id, number, title, amount_delta, tax_delta, days_delta, kind |
invoice_created |
invoice_id, doc_number, amount_cents, status |
payment_received |
invoice_id, doc_number, amount_cents, status, paid_at |
Job and proposal event payload#
{
"event_type": "job_status_changed",
"event_id": "5f2c6d8e-8a52-4a1e-9f0a-1c2d3e4f5a6b",
"created_at": "2026-10-12T17:04:11.512345Z",
"firm_id": 311,
"status_changed": true,
"previous_status": "estimate",
"current_status": "approved",
"job": {
"id": 4812,
"external_ref": "ERP-JOB-20931",
"hubspot_deal_id": null,
"hubspot_contact_id": null,
"hubspot_company_id": null,
"site_id": "S000142",
"estimate_id": 4410,
"estimate_number": 1051,
"estimate_total": 43210.46,
"location_id": 2290,
"location_external_ref": "SITE-0142",
"client_account_id": 915,
"client_account_external_ref": "CUST-0091",
"name": "Northgate Plaza Mill and Pave",
"company": "Northgate Property Group",
"contact_name": "Alex Rivera",
"contact_email": "[email protected]",
"contact_phone": "555-0142",
"address": "1200 Commerce Dr, Austin, TX",
"description": null,
"notes": null,
"job_type": "Paving",
"status": "approved",
"status_label": "Approved",
"source": "api",
"priority": "normal",
"estimated_value": 48250.0,
"probability": 80,
"start_date": "2026-10-20",
"end_date": null,
"next_follow_up": null,
"measurement_count": 2,
"firm_id": 311,
"assigned_to_user_id": 1204,
"assigned_to": {"id": 1204, "name": "Sam Estimator", "email": "[email protected]"},
"created_at": "2026-10-01T14:02:09.331201",
"updated_at": "2026-10-12T17:04:11.498812"
}
}
- Location and customer fields (
location_id,location_external_ref,client_account_id,client_account_external_ref) are always present. They arenullwhen unknown. proposal_*events add a top-levelproposalobject:id,kind(the proposal record type, treat as an opaque string),title,status,total,sent_at,actor_user_id.- These
event_idvalues are not the same as the event feed's ids forjob_created/job_status_changed. The same event keeps the sameevent_idacross retries and replays.
Receiving a delivery#
Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
TruTec-Webhooks/1.0 |
X-TruTec-Event |
The event type, e.g. job_won |
X-TruTec-Delivery |
A new UUID for every attempt |
X-TruTec-Timestamp |
Unix time in seconds when the attempt was signed |
X-TruTec-Signature |
sha256=<hex digest>. Sent only when the subscription has a secret |
Reply with any 2xx within 8 seconds. Do the real work after you reply, for example on a queue. Any other status, a timeout or a connection error counts as a failure.
Delivery is at least once. You can receive the same event more than once. Dedupe on event_id, not on X-TruTec-Delivery.
Deliveries can arrive out of order. Use occurred_at (business events) or the event feed id to order them.
Verifying signatures#
The signature is HMAC-SHA256, keyed with your subscription secret, over this string:
{X-TruTec-Timestamp}.{raw request body}
The header value is sha256= followed by the lowercase hex digest. Always verify against the raw bytes you received. Do not parse and re-serialize the JSON first. To block replayed requests, also reject timestamps more than a few minutes old.
import hashlib
import hmac
import time
def verify_trutec_webhook(secret: str, headers, raw_body: bytes, tolerance=300) -> bool:
timestamp = headers.get("X-TruTec-Timestamp", "")
signature = headers.get("X-TruTec-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode("utf-8") + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Flask example
# @app.post("/trutec/webhooks")
# def trutec_webhook():
# if not verify_trutec_webhook(SECRET, request.headers, request.get_data()):
# abort(401)
# event = request.get_json()
# ...
# return "", 204
const crypto = require("crypto");
function verifyTrutecWebhook(secret, headers, rawBody, toleranceSec = 300) {
const timestamp = headers["x-trutec-timestamp"] || "";
const signature = headers["x-trutec-signature"] || "";
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody) // Buffer of the exact bytes received
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: keep the raw body
// app.post("/trutec/webhooks", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyTrutecWebhook(process.env.TRUTEC_WEBHOOK_SECRET, req.headers, req.body)) {
// return res.sendStatus(401);
// }
// const event = JSON.parse(req.body.toString("utf8"));
// res.sendStatus(204);
// });
Retries#
A failed delivery is retried with backoff. There are 7 attempts in total. Each retry waits at least this long after the previous failure:
| After failure | Next attempt no sooner than |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 24 hours |
| 7 | No more retries. The delivery is marked failed |
Retries run on a periodic sweep, so an attempt can arrive up to about 10 minutes after the time shown. The full schedule covers about 33 hours.
If a subscription is deactivated, its pending deliveries are abandoned.
After the last attempt, the event is still in the event feed. Use redeliver or replay to send it again.
Manage subscriptions#
List webhooks#
GET /v1/webhooks
Permission: Any valid key
Lists the workspace's subscriptions, newest first. Deactivated subscriptions are included with "active": false.
curl https://trutec.ai/api/v1/webhooks \
-H "X-API-Key: sk_your_api_key_here"
{
"data": [
{
"id": 77,
"event_type": "all",
"target_url": "https://erp.example.com/trutec/webhooks",
"active": true,
"has_secret": true,
"created_at": "2026-10-12T13:48:40.961430"
}
]
}
The secret itself is never returned.
Create a webhook#
POST /v1/webhooks
Permission: Write jobs
| Name | Type | Required | Description |
|---|---|---|---|
target_url |
string | Yes | Must start with https:// or http://. Use https://. Max 500 characters. |
event_type |
string | No | See Subscription names. Default job_status_changed. |
secret |
string | No | Signing secret, max 128 characters. Without it, deliveries are not signed. Always set one. |
curl -X POST https://trutec.ai/api/v1/webhooks \
-H "X-API-Key: sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"event_type": "all", "target_url": "https://erp.example.com/trutec/webhooks", "secret": "whsec_7f3a9c..."}'
Returns 201 with {"data": {...}} in the same shape as the list.
Errors:
400event_type must be one of: ...(the message lists every accepted name)400target_url is required/target_url must start with http:// or https://403API key lacks job write permission
Update a webhook#
PATCH /v1/webhooks/{id}
Permission: Write jobs
Send only the fields you want to change: event_type, target_url, secret, active. Set "active": true to turn a deactivated subscription back on. Send a new secret to rotate it.
curl -X PATCH https://trutec.ai/api/v1/webhooks/77 \
-H "X-API-Key: sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"secret": "whsec_new_secret", "active": true}'
Returns 200 with {"data": {...}}. 404 Webhook subscription not found. 400 target_url cannot be empty.
Deactivate a webhook#
DELETE /v1/webhooks/{id}
Permission: Write jobs
Sets active to false. The subscription and its delivery history are kept. Returns 200 with the updated subscription.
curl -X DELETE https://trutec.ai/api/v1/webhooks/77 \
-H "X-API-Key: sk_your_api_key_here"
Delivery log#
List deliveries#
GET /v1/webhooks/deliveries
Permission: Read jobs
One row per delivery, newest first. Includes job and proposal events and business events.
| Name | Type | Required | Description |
|---|---|---|---|
subscription_id |
integer | No | Only this subscription |
status |
string | No | pending, delivered or failed |
since |
ISO 8601 | No | Deliveries created at or after this time |
limit |
integer | No | Default 50, max 200 |
offset |
integer | No | Default 0 |
curl "https://trutec.ai/api/v1/webhooks/deliveries?status=failed&limit=20" \
-H "X-API-Key: sk_your_api_key_here"
{
"data": [
{
"id": 90211,
"subscription_id": 77,
"event_type": "payment_received",
"event_id": "a09a3da4-9047-4069-b5ee-58bd934979cf",
"status": "failed",
"attempts": 7,
"last_status_code": 503,
"last_error": "HTTP 503: Service Unavailable",
"next_attempt_at": null,
"delivered_at": null,
"created_at": "2026-10-10T08:12:44Z"
}
],
"pagination": {"total": 1, "limit": 20, "offset": 0}
}
status is delivered (a 2xx was received), pending (another attempt is scheduled; next_attempt_at says when) or failed (no more attempts). last_error holds the start of your response body or the connection error.
Errors: 400 status must be pending, delivered or failed, 400 since must be an ISO 8601 timestamp.
Replay and redelivery#
Both endpoints queue new deliveries and return 202. Sending starts right away. The original delivery rows are kept.
Redeliver one delivery#
POST /v1/webhooks/deliveries/{id}/redeliver
Permission: Write jobs
Queues the same payload again (same event_id) as a new delivery with a new id.
curl -X POST https://trutec.ai/api/v1/webhooks/deliveries/90211/redeliver \
-H "X-API-Key: sk_your_api_key_here"
Returns 202 with {"data": {...}}: the new delivery, in the delivery log shape.
Errors: 404 Delivery not found, 409 Subscription is inactive; reactivate it before redelivering.
Replay a time window#
POST /v1/webhooks/replay
Permission: Write jobs
Re-sends everything one subscription should have received in a time window.
| Name | Type | Required | Description |
|---|---|---|---|
subscription_id |
integer | Yes | The subscription to replay to |
since |
ISO 8601 | Yes | Start of the window (inclusive) |
until |
ISO 8601 | No | End of the window (exclusive) |
event_type |
string | No | Comma-separated list to narrow the replay |
curl -X POST https://trutec.ai/api/v1/webhooks/replay \
-H "X-API-Key: sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"subscription_id": 77, "since": "2026-10-10T00:00:00Z", "until": "2026-10-11T00:00:00Z"}'
{"data": {"queued": 214, "subscription_id": 77, "capped": false}}
How replay works:
- Business event subscriptions (
allor one business event): payloads are rebuilt from the event log for events whoseoccurred_atis in the window.event_idis unchanged.jobandestimateshow the record as it is now. - Job and proposal subscriptions (
*or one job or proposal event): the subscription's own past deliveries created in the window are sent again, once per original event, with the original payload. - At most 5,000 deliveries are queued per call. If
cappedistrue, split the window into smallersince/untilranges. - Your receiver will see events it already has. Dedupe on
event_id.
Errors: 400 since is required, 404 subscription_id not found, 409 Subscription is inactive; reactivate it before replaying.
Event feed#
List events#
GET /v1/events
Permission: Read jobs
The workspace's event log, oldest first, ordered by id. This is the backbone for incremental sync.
| Name | Type | Required | Description |
|---|---|---|---|
after_id |
integer | No | Return events with id greater than this. Your cursor |
limit |
integer | No | Default 100, max 500 |
event_type |
string | No | Comma-separated. Underscore or dotted names |
job_id |
integer | No | Only events for this job |
since |
ISO 8601 | No | occurred_at at or after this time |
until |
ISO 8601 | No | occurred_at before this time |
curl "https://trutec.ai/api/v1/events?after_id=1200&limit=2" \
-H "X-API-Key: sk_your_api_key_here"
{
"data": [
{
"id": 1201,
"event_id": "0278d33c-a330-4691-b1fa-98f9402e64a4",
"event_type": "job_status_changed",
"occurred_at": "2026-10-12T13:48:41Z",
"entity": {"type": "job", "id": 4812},
"job_id": 4812,
"data": {"from_status": "estimate", "to_status": "won"}
},
{
"id": 1202,
"event_id": "8f7af1dc-3a9a-4b9c-99ff-1a7d85a457c8",
"event_type": "job_won",
"occurred_at": "2026-10-12T13:48:41Z",
"entity": {"type": "job", "id": 4812},
"job_id": 4812,
"data": {"from_status": "estimate", "to_status": "won", "source_event_id": "0278d33c-a330-4691-b1fa-98f9402e64a4"}
}
],
"next_after_id": 1202,
"has_more": true
}
Feed rows carry the event and its data only. They do not embed the job or estimate objects. Fetch those by id if you need them.
Incremental sync with the cursor#
- On first sync, call
GET /v1/eventswith no cursor. Or start from a full bulk export and then follow the feed. - Save
next_after_idfrom each page in your database. - While
has_moreistrue, call again withafter_id=<next_after_id>. - When
has_moreisfalse, you are caught up. Poll again later with the same cursor. An empty page returns yourafter_idunchanged innext_after_id.
import requests
BASE = "https://trutec.ai/api/v1"
HEADERS = {"X-API-Key": "sk_your_api_key_here"}
def sync_events(cursor, handle):
while True:
params = {"limit": 500}
if cursor:
params["after_id"] = cursor
page = requests.get(f"{BASE}/events", headers=HEADERS, params=params, timeout=30).json()
for event in page["data"]:
handle(event) # upsert keyed on event["event_id"]
cursor = page["next_after_id"]
save_cursor(cursor) # persist after each page
if not page["has_more"]:
return cursor
Note
Each event is written in the same transaction as the change it records. When two changes commit at almost the same moment, an event with a lower id can become visible a moment after one with a higher id. To be safe, re-read a short overlap (for example, the last few minutes with since=) on each poll and dedupe on event_id.
Feed facts:
occurred_atis UTC and never rewritten.idonly increases.- Events are kept. You can page from
after_id=0at any time. There is no documented retention limit. - The feed holds business events plus raw
job_createdandjob_status_changedrows. It does not holdjob_updatedorproposal_*. To catch other job edits, pollGET /v1/jobs?updated_after=(see Jobs) or use the bulk export. - Hard-deleted records produce no event. Run a periodic full export to reconcile.
Errors: 400 since must be an ISO 8601 timestamp (same for until), 403 API key lacks job read permission.