Skip to main content
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, 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:
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:

Payload envelope

Every webhook delivery has the same outer shape:
  • 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 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):

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 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.
For a completion photo:

Signature verification

Every request carries an X-SiteVisit-Signature header:
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

Python

Ruby

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.

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

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.