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:
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
16 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.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:
{
"id": "evt_abc123def456",
"event_type": "action_item.created",
"created_at": "2026-06-06T15:32:11.789Z",
"data": {
"action_item": {
"id": "ait_xyz789",
"site_visit_id": "svi_456",
"title": "Replace bulb at front entrance",
"priority": "HIGH",
"status": "OPEN",
"location": "Front lobby",
"...": "...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.
data contains the relevant resource(s). Always a JSON object, shape depends on event type.
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:
- Parse
t and v1 from the header.
- Reject the request if
|now - t| > 300 seconds (replay protection).
- Compute
HMAC-SHA256 over ${t}.${raw_body} with your endpoint’s signing secret.
- Constant-time compare your computed value with
v1.
Node.js
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
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
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
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:
| 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.
- 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:
# 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.