# Webhooks & events

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](partner-api.md).

## 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

```json
{
  "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](estimates-and-proposals.md).
- `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

```json
{
  "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": "alex@example.com",
    "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": "sam@example.com"},
    "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.

```python
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
```

```javascript
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](#replay-and-redelivery) 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`.

```bash
curl https://trutec.ai/api/v1/webhooks \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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](#subscription-names). Default `job_status_changed`. |
| `secret` | string | No | Signing secret, max 128 characters. Without it, deliveries are not signed. Always set one. |

```bash
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.

```bash
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.

```bash
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 |

```bash
curl "https://trutec.ai/api/v1/webhooks/deliveries?status=failed&limit=20" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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`.

```bash
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 |

```bash
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"}'
```

```json
{"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 |

```bash
curl "https://trutec.ai/api/v1/events?after_id=1200&limit=2" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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](bulk-export.md) 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`.

```python
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](jobs.md)) or use the [bulk export](bulk-export.md).
- 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`.
