> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sitevisit.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive HTTPS POSTs when something happens in a SiteVisit account.

When something changes on your account — an action item gets filed, a visit completes, evidence gets attached — SiteVisit can POST a signed JSON payload to a URL you control. Use it to keep a work-order system in sync, post notifications to Slack, feed a data warehouse, or fan out into any other downstream system.

Webhooks share authentication style with [Stripe](https://docs.stripe.com/webhooks), so any code you've written to verify a Stripe webhook ports over with a one-line constant swap.

## Setting up an endpoint

Two ways:

**From the dashboard** — open **Settings → Developers → Webhooks**, click **Add endpoint**, paste your URL, pick the events to subscribe to (or "all"), and save. We'll display the signing secret **once** — copy it now, store it in your secret manager.

**Via the API** — issue a key in **Settings → Developers**, then:

```bash theme={null}
curl -X POST https://sitevisit.app/api/v1/webhook-endpoints \
  -H "Authorization: Bearer sv_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production work-order sync",
    "url": "https://your-server.com/webhooks/sitevisit",
    "events": ["action_item.created", "action_item.completed", "site_visit.ready"]
  }'
```

The response includes a `signingSecret` field — **save it now**. We hash nothing here (we need the raw secret on every send to sign), but the value is never returned again after creation. Rotate by deleting + recreating the endpoint.

Pass `"events": []` to subscribe to **everything** (recommended for first integration — you can ignore events you don't care about on your side).

## Event catalog

17 event types across four domains:

| Event                   | When                                                         |
| ----------------------- | ------------------------------------------------------------ |
| `property.created`      | New property added                                           |
| `property.updated`      | Name / address / notes / icon edited                         |
| `property.deleted`      | Property removed (cascades downstream)                       |
| `site_visit.created`    | New visit filed                                              |
| `site_visit.updated`    | Title / visit date / summary / notes edited                  |
| `site_visit.processing` | Video upload begins processing                               |
| `site_visit.ready`      | AI extraction complete, report ready                         |
| `site_visit.sent`       | Report emailed to recipients                                 |
| `site_visit.completed`  | Visit marked done (explicitly OR implicitly via send)        |
| `site_visit.deleted`    | Visit removed                                                |
| `action_item.created`   | New item filed (AI extraction OR manual)                     |
| `action_item.updated`   | Title / priority / location / category / description changed |
| `action_item.assigned`  | Assignee specifically changed (fires alongside `updated`)    |
| `action_item.completed` | Marked DONE with attribution                                 |
| `action_item.reopened`  | DONE → OPEN / IN\_PROGRESS                                   |
| `action_item.deleted`   | Item removed                                                 |
| `evidence.attached`     | Photo / video attached to an action item                     |

## Payload envelope

Every webhook delivery has the same outer shape:

```json theme={null}
{
  "id": "evt_abc123def456",
  "event_type": "action_item.created",
  "livemode": true,
  "created_at": "2026-06-06T15:32:11.789Z",
  "data": {
    "action_item": {
      "id": "cmq1l7sf50009kv04tv2cgm4x",
      "title": "Replace bulb at front entrance",
      "...": "...full ActionItemDto"
    }
  }
}
```

* `id` is unique per event. **Dedupe on this in your handler** — we may deliver the same event more than once if your endpoint returns a non-2xx and we retry.
* `event_type` is stable across versions. Branch your logic on this string.
* `livemode` is `true` for events triggered by `sv_live_*` keys (real customer data) and `false` for events triggered by `sv_test_*` keys (sandbox dataset). Same Stripe convention; lets you ignore test traffic in production pipelines without parsing the resource itself.
* `created_at` is the time the event was emitted (UTC ISO-8601). Different from any timestamps inside `data`.
* `data` contains the relevant resource(s). Always a JSON object, shape depends on event type. See [Event payload reference](#event-payload-reference) below.

## Test mode

Webhook endpoints have a **mode** of either `live` or `test`, set when you create the endpoint in **Settings → Developers → Webhooks**. The mode determines which traffic the endpoint receives:

* **Live endpoints** only receive events from `sv_live_*` API keys, the dashboard, and OAuth flows. `livemode: true` on every payload.
* **Test endpoints** only receive events from `sv_test_*` keys. `livemode: false` on every payload.

The streams never cross — test traffic never fires a live endpoint, and vice versa. Same Stripe model: develop your integration against a test endpoint while you build, swap to a live endpoint when you ship.

When you create an endpoint via the API, pass `"mode": "test"` to make it a test endpoint (defaults to `"live"` when omitted):

```bash theme={null}
curl -X POST https://sitevisit.app/api/v1/webhook-endpoints \
  -H "Authorization: Bearer sv_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "local dev — ngrok tunnel",
    "url": "https://abc123.ngrok-free.app/webhooks/sitevisit",
    "mode": "test",
    "events": []
  }'
```

## Event payload reference

Every event's `data` object shape, with realistic example values.

DTO field names match what the v1 REST API returns — see the [API reference](/api-reference/introduction) for the full schema with descriptions per field.

### Property events

#### `property.created`

```json theme={null}
{
  "id": "evt_p1...",
  "event_type": "property.created",
  "livemode": true,
  "created_at": "2026-06-07T09:14:22.103Z",
  "data": {
    "property": {
      "id": "cmp1aB2c3D4e5F6g7H8i9J0k1",
      "name": "Sunset Vista Apartments",
      "address": "742 Evergreen Terrace, Springfield",
      "notes": null,
      "iconEmoji": "🏢",
      "coverImageUrl": null,
      "isSample": false,
      "isTest": false,
      "createdAt": "2026-06-07T09:14:22.001Z",
      "updatedAt": "2026-06-07T09:14:22.001Z",
      "visitCount": 0,
      "openItemCount": 0
    }
  }
}
```

#### `property.updated`

Fires on any patch of `name`, `address`, `notes`, or `iconEmoji`. Payload is the **full updated property** (not a diff) — so a single handler can refresh your local cache without tracking what changed.

```json theme={null}
{
  "id": "evt_p2...",
  "event_type": "property.updated",
  "livemode": true,
  "created_at": "2026-06-07T11:02:18.503Z",
  "data": {
    "property": {
      "id": "cmp1aB2c3D4e5F6g7H8i9J0k1",
      "name": "Sunset Vista (Building A)",
      "address": "742 Evergreen Terrace, Springfield",
      "notes": "Newer of the two buildings; AC retrofitted 2024",
      "iconEmoji": "🏢",
      "coverImageUrl": "https://files.sitevisit.app/...",
      "isSample": false,
      "isTest": false,
      "createdAt": "2026-06-07T09:14:22.001Z",
      "updatedAt": "2026-06-07T11:02:18.412Z",
      "visitCount": 3,
      "openItemCount": 7
    }
  }
}
```

#### `property.deleted`

Cascade: deleting a property deletes all its site visits + action items + evidence. We **don't** include the full property in this payload (it's already gone from our DB by the time we send) — just the id.

```json theme={null}
{
  "id": "evt_p3...",
  "event_type": "property.deleted",
  "livemode": true,
  "created_at": "2026-06-07T14:55:09.722Z",
  "data": {
    "property_id": "cmp1aB2c3D4e5F6g7H8i9J0k1"
  }
}
```

### Site visit events

#### `site_visit.created`

```json theme={null}
{
  "id": "evt_v1...",
  "event_type": "site_visit.created",
  "livemode": true,
  "created_at": "2026-06-07T08:30:00.044Z",
  "data": {
    "site_visit": {
      "id": "cmv1aB2c3D4e5F6g7H8i9J0k1",
      "propertyId": "cmp1aB2c3D4e5F6g7H8i9J0k1",
      "propertyName": "Sunset Vista Apartments",
      "title": "Q2 maintenance walkthrough",
      "status": "DRAFT",
      "visitDate": "2026-06-07T08:30:00.000Z",
      "summary": null,
      "notes": null,
      "recipients": [],
      "sentAt": null,
      "completedAt": null,
      "isTest": false,
      "createdAt": "2026-06-07T08:30:00.001Z",
      "updatedAt": "2026-06-07T08:30:00.001Z",
      "actionItemCount": 0,
      "openActionItemCount": 0,
      "publicReportUrl": "https://sitevisit.app/r/cmv1aB2c3D4e5F6g7H8i9J0k1"
    }
  }
}
```

#### `site_visit.updated`

Fires on any patch of `title`, `visitDate`, `summary`, or `notes`. Status transitions (DRAFT → PROCESSING → READY → SENT → DONE) fire their own dedicated events instead and are **not** emitted as `updated`.

```json theme={null}
{
  "id": "evt_v2...",
  "event_type": "site_visit.updated",
  "livemode": true,
  "created_at": "2026-06-07T08:45:11.122Z",
  "data": {
    "site_visit": { /* same shape as site_visit.created above */ }
  }
}
```

#### `site_visit.processing`

The video upload pipeline started transcoding + transcribing. No additional fields beyond the visit DTO.

```json theme={null}
{
  "id": "evt_v3...",
  "event_type": "site_visit.processing",
  "livemode": true,
  "created_at": "2026-06-07T08:46:02.788Z",
  "data": {
    "site_visit": { /* status: "PROCESSING" */ }
  }
}
```

#### `site_visit.ready`

The AI extraction finished — transcript, summary, and action items are all available. **This is the event to subscribe to if you want to fan visits into a work-order system once they're truly actionable.**

```json theme={null}
{
  "id": "evt_v4...",
  "event_type": "site_visit.ready",
  "livemode": true,
  "created_at": "2026-06-07T08:51:34.901Z",
  "data": {
    "site_visit": { /* status: "READY", summary populated, actionItemCount > 0 */ }
  }
}
```

#### `site_visit.sent`

The asset manager hit the **Email report** button. `recipients` is the list of email addresses the report went to; `sentAt` is when.

```json theme={null}
{
  "id": "evt_v5...",
  "event_type": "site_visit.sent",
  "livemode": true,
  "created_at": "2026-06-07T09:15:22.011Z",
  "data": {
    "site_visit": {
      "id": "cmv1aB2c3D4e5F6g7H8i9J0k1",
      "title": "Q2 maintenance walkthrough",
      "property_id": "cmp1aB2c3D4e5F6g7H8i9J0k1",
      "sent_at": "2026-06-07T09:15:21.998Z",
      "recipients": ["maintenance@happyco.com", "ops@example.com"]
    }
  }
}
```

#### `site_visit.completed`

Fires on the **transition** to completed only — won't re-fire on subsequent edits of an already-completed visit. Sending a report implicitly completes the visit, so this fires alongside `site_visit.sent` for the first send.

```json theme={null}
{
  "id": "evt_v6...",
  "event_type": "site_visit.completed",
  "livemode": true,
  "created_at": "2026-06-07T09:15:22.245Z",
  "data": {
    "site_visit": {
      "id": "cmv1aB2c3D4e5F6g7H8i9J0k1",
      "title": "Q2 maintenance walkthrough",
      "property_id": "cmp1aB2c3D4e5F6g7H8i9J0k1",
      "sent_at": "2026-06-07T09:15:21.998Z",
      "completed_at": "2026-06-07T09:15:21.998Z",
      "recipients": ["maintenance@happyco.com", "ops@example.com"]
    }
  }
}
```

#### `site_visit.deleted`

```json theme={null}
{
  "id": "evt_v7...",
  "event_type": "site_visit.deleted",
  "livemode": true,
  "created_at": "2026-06-07T10:00:00.000Z",
  "data": {
    "site_visit_id": "cmv1aB2c3D4e5F6g7H8i9J0k1"
  }
}
```

### Action item events

#### `action_item.created`

Fires for both AI-extracted items (from the video transcription pipeline) and manually-filed items (via REST / MCP / dashboard).

```json theme={null}
{
  "id": "evt_a1...",
  "event_type": "action_item.created",
  "livemode": true,
  "created_at": "2026-06-07T08:51:36.124Z",
  "data": {
    "action_item": {
      "id": "cai1aB2c3D4e5F6g7H8i9J0k1",
      "siteVisitId": "cmv1aB2c3D4e5F6g7H8i9J0k1",
      "propertyId": "cmp1aB2c3D4e5F6g7H8i9J0k1",
      "propertyName": "Sunset Vista Apartments",
      "title": "Loose handrail on 2nd-floor landing",
      "description": "Wobbles ~2cm when grabbed; bolts need re-tightening",
      "category": "Safety",
      "location": "Building A, stairwell 2",
      "priority": "HIGH",
      "status": "OPEN",
      "assignee": null,
      "timestampSec": 154,
      "transcriptQuote": "...this rail is loose, you can feel it move when you grab it...",
      "completedAt": null,
      "completedBy": null,
      "completionNote": null,
      "completionPhotoUrl": null,
      "evidenceImageUrls": [
        "https://files.sitevisit.app/...",
        "https://files.sitevisit.app/..."
      ],
      "isTest": false,
      "order": 0,
      "createdAt": "2026-06-07T08:51:36.001Z",
      "updatedAt": "2026-06-07T08:51:36.001Z"
    }
  }
}
```

#### `action_item.updated`

Fires on any patch of `title`, `description`, `category`, `location`, `priority`, `status`, or `assignee`. **Always fires** when one of those fields changes — the more specific events below (`assigned`, `completed`, `reopened`) fire **in addition**, not instead.

```json theme={null}
{
  "id": "evt_a2...",
  "event_type": "action_item.updated",
  "livemode": true,
  "created_at": "2026-06-07T10:30:00.000Z",
  "data": {
    "action_item": { /* same shape as action_item.created */ }
  }
}
```

#### `action_item.assigned`

Fires when the `assignee` field changes (including being set for the first time or cleared back to null). Includes the previous assignee value for audit trails.

```json theme={null}
{
  "id": "evt_a3...",
  "event_type": "action_item.assigned",
  "livemode": true,
  "created_at": "2026-06-07T10:30:00.122Z",
  "data": {
    "action_item": { /* full DTO, assignee field updated */ },
    "previous_assignee": null
  }
}
```

#### `action_item.completed`

Fires when an item transitions to `status: "DONE"` via the `POST /action-items/{id}/complete` endpoint (the path that captures attribution + completion note + photo). A plain `PATCH` to `status: "DONE"` will fire `action_item.updated` but **not** `action_item.completed` — use the dedicated complete endpoint when you want the attribution trail.

```json theme={null}
{
  "id": "evt_a4...",
  "event_type": "action_item.completed",
  "livemode": true,
  "created_at": "2026-06-07T14:22:01.555Z",
  "data": {
    "action_item": {
      "id": "cai1aB2c3D4e5F6g7H8i9J0k1",
      "title": "Loose handrail on 2nd-floor landing",
      "status": "DONE",
      "priority": "HIGH",
      "completedAt": "2026-06-07T14:22:01.444Z",
      "completedBy": "Mike Rivera",
      "completionNote": "Re-tightened all four bolts; tested by leaning hard against the rail",
      "completionPhotoUrl": "https://files.sitevisit.app/...",
      "evidenceImageUrls": ["...", "..."],
      "isTest": false,
      "...": "...full ActionItemDto"
    }
  }
}
```

#### `action_item.reopened`

Fires when an item transitions from `DONE` back to `OPEN` or `IN_PROGRESS`. Includes the previous status value.

```json theme={null}
{
  "id": "evt_a5...",
  "event_type": "action_item.reopened",
  "livemode": true,
  "created_at": "2026-06-07T15:10:00.000Z",
  "data": {
    "action_item": { /* full DTO, status flipped back to OPEN */ },
    "previous_status": "DONE"
  }
}
```

#### `action_item.deleted`

```json theme={null}
{
  "id": "evt_a6...",
  "event_type": "action_item.deleted",
  "livemode": true,
  "created_at": "2026-06-07T16:00:00.000Z",
  "data": {
    "action_item_id": "cai1aB2c3D4e5F6g7H8i9J0k1"
  }
}
```

### Evidence events

#### `evidence.attached`

Fires when a photo or video clip gets attached to an action item — either as **regular evidence** (via the capture-token flow during a walkthrough) or as a **completion photo** (proving the work was done). The `target_type` field tells you which case it is. For completion photos, `evidence.id` is `null` because they don't get their own EvidenceImage row — they're stored on the action item's `completionPhotoUrl` directly.

```json theme={null}
{
  "id": "evt_e1...",
  "event_type": "evidence.attached",
  "livemode": true,
  "created_at": "2026-06-07T11:15:32.901Z",
  "data": {
    "evidence": {
      "id": "cev1aB2c3D4e5F6g7H8i9J0k1",
      "action_item_id": "cai1aB2c3D4e5F6g7H8i9J0k1",
      "url": "https://files.sitevisit.app/...",
      "caption": "Close-up of the loose bolt"
    },
    "target_type": "action_item"
  }
}
```

For a completion photo:

```json theme={null}
{
  "id": "evt_e2...",
  "event_type": "evidence.attached",
  "livemode": true,
  "created_at": "2026-06-07T14:22:01.601Z",
  "data": {
    "evidence": {
      "id": null,
      "action_item_id": "cai1aB2c3D4e5F6g7H8i9J0k1",
      "url": "https://files.sitevisit.app/...",
      "caption": null
    },
    "target_type": "action_item_completion"
  }
}
```

## Signature verification

Every request carries an `X-SiteVisit-Signature` header:

```
X-SiteVisit-Signature: t=1717689600,v1=a3b1c2d4e5f6...
```

Where `v1` is `HMAC-SHA256(secret, t + "." + body)` hex-encoded.

To verify:

1. Parse `t` and `v1` from the header.
2. Reject the request if `|now - t| > 300` seconds (replay protection).
3. Compute `HMAC-SHA256` over `${t}.${raw_body}` with your endpoint's signing secret.
4. **Constant-time compare** your computed value with `v1`.

### Node.js

```javascript theme={null}
import crypto from "node:crypto";

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("="))
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(t) || !v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

### Python

```python theme={null}
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    v1 = parts.get("v1", "")
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{t}.{raw_body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, v1)
```

### Ruby

```ruby theme={null}
require "openssl"

def verify(raw_body, header, secret, tolerance = 300)
  parts = header.split(",").map { |kv| kv.split("=", 2) }.to_h
  t  = parts["t"].to_i
  v1 = parts["v1"]
  return false if (Time.now.to_i - t).abs > tolerance

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
  OpenSSL::Digest.compare(expected, v1) rescue false
end
```

<Warning>
  **Always use a constant-time comparison** (`crypto.timingSafeEqual`, `hmac.compare_digest`, `OpenSSL::Digest.compare`). A naive `===` leaks the secret's length and trailing bytes via timing side-channels.
</Warning>

## Retry behavior

If your endpoint returns anything other than a 2xx status (or times out — we cap at 10 seconds), we'll retry with this backoff:

| Attempt | After               |
| ------- | ------------------- |
| 1       | enqueue (immediate) |
| 2       | +1 min              |
| 3       | +5 min              |
| 4       | +15 min             |
| 5       | +1 h                |
| 6       | +6 h                |

After 6 failed attempts (\~7 hours total), we mark the delivery `abandoned`. The Settings → Webhooks delivery log surfaces the last response we got so you can debug.

## Best practices for your receiver

* **Dedupe on `event.id`.** Webhook delivery is at-least-once, not exactly-once. Persist the IDs you've processed and skip duplicates.
* **Verify the signature on every request, before doing any work.** Forged requests look identical to real ones at the HTTP level.
* **Branch on `livemode` before mutating production state.** Live and test events both reach a single endpoint URL only if you configured the endpoint that way — but having an explicit `if (!livemode) return ok();` short-circuit in your live handler is cheap insurance against misconfigured endpoints.
* **Respond fast.** Acknowledge with a 200 within a few seconds and do real work asynchronously. We treat anything longer than 10s as a failure and queue a retry.
* **Subscribe to all events on first integration.** Filter on your side. Adding subscriptions later requires updating the endpoint config, which is a deploy in your world; ignoring an event you didn't want is a one-liner.

## Disabling / deleting an endpoint

From the dashboard: **Settings → Developers → Webhooks** → click **Disable** to soft-suspend (preserves config + delivery history but stops new deliveries), or **Delete** to remove entirely (cascades to delivery rows). Either takes effect on the next cron tick (\~60s).

Via the API:

```bash theme={null}
# Soft-disable
curl -X PATCH https://sitevisit.app/api/v1/webhook-endpoints/<id> \
  -H "Authorization: Bearer sv_live_..." \
  -H "Content-Type: application/json" \
  -d '{"disabled": true}'

# Delete
curl -X DELETE https://sitevisit.app/api/v1/webhook-endpoints/<id> \
  -H "Authorization: Bearer sv_live_..."
```

## Testing your handler before going live

The dashboard's **Send test** button (per endpoint) fires a synthetic `test.ping` event through the real delivery pipeline. The envelope shape is identical to a real event, so if your code handles the test ping correctly, it'll handle real events correctly too.

The `test.ping` event is **not** in the regular event catalog and is only emitted by the explicit dashboard button — it'll never fire from normal account activity.
