Skip to content
TruTecDevelopers
Get an API key

Webhooks & events

Signed webhooks with retries and replay, plus an ordered event feed you can page with a cursor for incremental sync.

View as Markdown

TruTec tells your system about changes in two ways:

  • Webhooks push a signed JSON POST to 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_id is a UUID. It is the same in the feed, in every retry and in a replay. Dedupe on it.
  • job is included when the event belongs to a job. It has the same shape as the job object in the job and proposal events below.
  • estimate is included on estimate_* events. It is the structured estimate with line items, the same as GET /v1/estimates/{id} (with ?version=N for issued and revised versions). See Estimates & proposals.
  • job and estimate are 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 are null when unknown.
  • proposal_* events add a top-level proposal object: id, kind (the proposal record type, treat as an opaque string), title, status, total, sent_at, actor_user_id.
  • These event_id values are not the same as the event feed's ids for job_created / job_status_changed. The same event keeps the same event_id across 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:

  • 400 event_type must be one of: ... (the message lists every accepted name)
  • 400 target_url is required / target_url must start with http:// or https://
  • 403 API 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 (all or one business event): payloads are rebuilt from the event log for events whose occurred_at is in the window. event_id is unchanged. job and estimate show 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 capped is true, split the window into smaller since/until ranges.
  • 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#

  1. On first sync, call GET /v1/events with no cursor. Or start from a full bulk export and then follow the feed.
  2. Save next_after_id from each page in your database.
  3. While has_more is true, call again with after_id=<next_after_id>.
  4. When has_more is false, you are caught up. Poll again later with the same cursor. An empty page returns your after_id unchanged in next_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_at is UTC and never rewritten. id only increases.
  • Events are kept. You can page from after_id=0 at any time. There is no documented retention limit.
  • The feed holds business events plus raw job_created and job_status_changed rows. It does not hold job_updated or proposal_*. To catch other job edits, poll GET /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.