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:
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:
EventWhen
property.createdNew property added
property.updatedName / address / notes / icon edited
property.deletedProperty removed (cascades downstream)
site_visit.createdNew visit filed
site_visit.processingVideo upload begins processing
site_visit.readyAI extraction complete, report ready
site_visit.sentReport emailed to recipients
site_visit.completedVisit marked done (explicitly OR implicitly via send)
site_visit.deletedVisit removed
action_item.createdNew item filed (AI extraction OR manual)
action_item.updatedTitle / priority / location / category / description changed
action_item.assignedAssignee specifically changed (fires alongside updated)
action_item.completedMarked DONE with attribution
action_item.reopenedDONE → OPEN / IN_PROGRESS
action_item.deletedItem removed
evidence.attachedPhoto / 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:
  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

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:
AttemptAfter
1enqueue (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.