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: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:Payload envelope
Every webhook delivery has the same outer shape:idis 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_typeis stable across versions. Branch your logic on this string.livemodeistruefor events triggered bysv_live_*keys (real customer data) andfalsefor events triggered bysv_test_*keys (sandbox dataset). Same Stripe convention; lets you ignore test traffic in production pipelines without parsing the resource itself.created_atis the time the event was emitted (UTC ISO-8601). Different from any timestamps insidedata.datacontains the relevant resource(s). Always a JSON object, shape depends on event type. See Event payload reference below.
Test mode
Webhook endpoints have a mode of eitherlive 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: trueon every payload. - Test endpoints only receive events from
sv_test_*keys.livemode: falseon every payload.
"mode": "test" to make it a test endpoint (defaults to "live" when omitted):
Event payload reference
Every event’sdata object shape, with realistic example values.
DTO field names match what the v1 REST API returns — see the API reference for the full schema with descriptions per field.
Property events
property.created
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.
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.
Site visit events
site_visit.created
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.
site_visit.processing
The video upload pipeline started transcoding + transcribing. No additional fields beyond the visit DTO.
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.
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.
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.
site_visit.deleted
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).
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.
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.
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.
action_item.reopened
Fires when an item transitions from DONE back to OPEN or IN_PROGRESS. Includes the previous status value.
action_item.deleted
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.
Signature verification
Every request carries anX-SiteVisit-Signature header:
v1 is HMAC-SHA256(secret, t + "." + body) hex-encoded.
To verify:
- Parse
tandv1from the header. - Reject the request if
|now - t| > 300seconds (replay protection). - Compute
HMAC-SHA256over${t}.${raw_body}with your endpoint’s signing secret. - Constant-time compare your computed value with
v1.
Node.js
Python
Ruby
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:
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
livemodebefore mutating production state. Live and test events both reach a single endpoint URL only if you configured the endpoint that way — but having an explicitif (!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:Testing your handler before going live
The dashboard’s Send test button (per endpoint) fires a synthetictest.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.