# Complete a site visit Source: https://docs.sitevisit.app/api-reference/complete-a-site-visit /api-reference/openapi.json post /site-visits/{id}/complete Mark a visit complete without sending a report. Idempotent — calling on an already-completed visit returns the existing record without changing the original timestamp. Mirrors the dashboard's 'Mark Complete' button. # Complete an action item Source: https://docs.sitevisit.app/api-reference/complete-an-action-item /api-reference/openapi.json post /action-items/{id}/complete Mark an action item DONE with attribution + an optional note + completion photo storage key. Idempotent at the domain level — completing an already-done item returns the existing record without changing the original timestamp. # Create a property Source: https://docs.sitevisit.app/api-reference/create-a-property /api-reference/openapi.json post /properties Create a property on the authenticated user's account. Returns 402 with an upgrade URL when the user's plan property limit is reached. # Create a site visit Source: https://docs.sitevisit.app/api-reference/create-a-site-visit /api-reference/openapi.json post /site-visits Create a site visit on a property the caller owns. Starts in DRAFT status. Returns 402 with upgrade URL if the plan visit limit is hit, 404 if the property doesn't exist or isn't owned. # Create a webhook endpoint Source: https://docs.sitevisit.app/api-reference/create-a-webhook-endpoint /api-reference/openapi.json post /webhook-endpoints Subscribe a new URL to events for this account. The `signingSecret` is included in the response **exactly once** — store it now. To rotate, delete the endpoint and create a new one. URLs must be HTTPS in production (HTTP allowed only for `localhost` during local development). Pass an `Idempotency-Key` for safe retries. # Create an action item Source: https://docs.sitevisit.app/api-reference/create-an-action-item /api-reference/openapi.json post /action-items Manually create an action item attached to a site visit the caller owns. Useful for filing items from natural language (email, chat, voice) without going through the AI extraction flow on uploaded videos. # Delete a property Source: https://docs.sitevisit.app/api-reference/delete-a-property /api-reference/openapi.json delete /properties/{id} Permanently delete a property the caller owns. Cascades to all site visits, action items, evidence images, and uploaded videos on the property. Returns 204 No Content on success. Irreversible — there's no undo. # Delete a site visit Source: https://docs.sitevisit.app/api-reference/delete-a-site-visit /api-reference/openapi.json delete /site-visits/{id} Permanently delete a site visit the caller owns. Cascades to all action items, evidence, and uploaded videos on the visit. Returns 204 No Content on success. # Delete a webhook endpoint Source: https://docs.sitevisit.app/api-reference/delete-a-webhook-endpoint /api-reference/openapi.json delete /webhook-endpoints/{id} Hard-delete the endpoint and all of its delivery rows. Returns 204 No Content on success. To pause without losing history, PATCH with `disabled: true` instead. # Delete an action item Source: https://docs.sitevisit.app/api-reference/delete-an-action-item /api-reference/openapi.json delete /action-items/{id} Permanently delete an action item the caller owns. Cascades to attached evidence images. Returns 204 No Content on success. # Fetch a property Source: https://docs.sitevisit.app/api-reference/fetch-a-property /api-reference/openapi.json get /properties/{id} Fetch a single property the caller owns. The canonical post-webhook fetch — when an integrator receives a property.* event with an id, this is how they pull the full record. # Fetch a site visit Source: https://docs.sitevisit.app/api-reference/fetch-a-site-visit /api-reference/openapi.json get /site-visits/{id} Fetch a single site visit the caller owns. Includes the public-report URL the asset manager would share with the team. Useful after receiving a site_visit.* webhook. # Fetch a webhook endpoint Source: https://docs.sitevisit.app/api-reference/fetch-a-webhook-endpoint /api-reference/openapi.json get /webhook-endpoints/{id} Fetch a single webhook endpoint by ID. Signing secret is **not** included — it's shown only at creation. # Fetch an action item Source: https://docs.sitevisit.app/api-reference/fetch-an-action-item /api-reference/openapi.json get /action-items/{id} Fetch a single action item the caller owns. Includes evidence image URLs and completion fields. Useful after receiving an action_item.* webhook to hydrate the full record. # Issue a capture-link token Source: https://docs.sitevisit.app/api-reference/issue-a-capture-link-token /api-reference/openapi.json post /capture-tokens Issue a short-lived URL/QR token granting media-upload access to a single resource (today: an action item's evidence or completion photo). The capture URL renders a phone-friendly camera view at /c/ with no sign-in required. 24h TTL by default, capped at 7 days. Pass an Idempotency-Key for safe retries. # List action items Source: https://docs.sitevisit.app/api-reference/list-action-items /api-reference/openapi.json get /action-items # List properties Source: https://docs.sitevisit.app/api-reference/list-properties /api-reference/openapi.json get /properties Returns the properties owned by the authenticated account. # List site visits Source: https://docs.sitevisit.app/api-reference/list-site-visits /api-reference/openapi.json get /site-visits # List webhook endpoints Source: https://docs.sitevisit.app/api-reference/list-webhook-endpoints /api-reference/openapi.json get /webhook-endpoints Return every webhook endpoint owned by the authenticated account. Signing secrets are **never** included on read — they're shown exactly once at creation. Not paginated; the per-account endpoint count is small enough to ship as a single response. # Update a property Source: https://docs.sitevisit.app/api-reference/update-a-property /api-reference/openapi.json patch /properties/{id} Patch any subset of (name, address, notes, icon_emoji). Returns the updated property. # Update a site visit Source: https://docs.sitevisit.app/api-reference/update-a-site-visit /api-reference/openapi.json patch /site-visits/{id} Patch any subset of (title, visit_date, summary, notes). Status transitions are driven by the video pipeline and aren't editable via the API. To close a visit use POST /site-visits/{id}/complete instead. # Update a webhook endpoint Source: https://docs.sitevisit.app/api-reference/update-a-webhook-endpoint /api-reference/openapi.json patch /webhook-endpoints/{id} Patch any subset of `name`, `url`, `events`, or `disabled`. To rotate the signing secret, delete the endpoint and create a new one. # Update an action item Source: https://docs.sitevisit.app/api-reference/update-an-action-item /api-reference/openapi.json patch /action-items/{id} Patch any subset of (title, description, category, location, priority, status, assignee). Closing with attribution + note + photo uses POST /action-items/{id}/complete instead. # Authentication Source: https://docs.sitevisit.app/authentication How to authenticate requests with an API key. The SiteVisit API uses **Bearer token authentication**. Every request must include an `Authorization` header carrying a key you generated in the dashboard. ```bash theme={null} Authorization: Bearer sv_live_e059b3544b7fa4b93c7e26933aeba781 ``` ## Key format ``` sv_live_<32 hex chars> ← real customer data sv_test_<32 hex chars> ← parallel sandbox dataset ``` * `sv_` is the SiteVisit prefix. * `live` or `test` identifies the **mode** — test keys operate on a parallel sandbox dataset that's completely separate from your production data. See [Test mode](#test-mode) below. * The tail is 16 random bytes encoded as 32 lowercase hex characters. We store only a SHA-256 hash of the tail and the prefix in cleartext (just enough to identify which key is which in the UI). On the wire we compare in constant time. ## Generate a key 1. Sign in to [sitevisit.app](https://sitevisit.app). 2. Open **Settings → Developers**. 3. Type a name (e.g. `production-warehouse`, `marc-laptop`). 4. Choose **Mode → Live** for production data, or **Mode → Test** for the sandbox. 5. Click **Generate key**. 6. **Copy the full value immediately** — we display it once. ## Test mode Stripe-style — `sv_test_*` keys operate on a **parallel sandbox dataset** that's completely separate from your production data. Properties, site visits, action items, capture tokens, and webhook endpoints created with a test key are tagged `isTest: true` and never visible to live-mode callers (or vice versa). **Test mode is free + unmetered.** Test resources don't count against your plan limits — developers on any tier can build end-to-end without paying. ```bash theme={null} # List your test-mode properties curl https://sitevisit.app/api/v1/properties \ -H "Authorization: Bearer sv_test_..." # Create a test property curl -X POST https://sitevisit.app/api/v1/properties \ -H "Authorization: Bearer sv_test_..." \ -H "Content-Type: application/json" \ -d '{"name": "Test Property"}' ``` Every response includes `isTest: true|false` so you can confirm at a glance which dataset you hit. ### Webhooks in test mode Webhook endpoints have their own mode (`live` or `test`, set when you create the endpoint in Settings → Developers → Webhooks). Test-mode endpoints **only** receive events triggered by `sv_test_*` keys; live endpoints **only** receive events from `sv_live_*` keys. Test traffic never crosses into live receivers. Every webhook payload includes a top-level `livemode` boolean so receivers can branch without parsing the resource itself: ```jsonc theme={null} { "id": "evt_abc123", "event_type": "property.created", "livemode": false, // ← true for sv_live_, false for sv_test_ "created_at": "2026-06-06T...", "data": { "property": { /* PropertyDto */ } } } ``` Once you close the reveal panel, the full secret is gone forever. We keep only a hash. If you lose it, revoke and re-issue. ## Rotate a key There's no rotation endpoint — generate a new key, swap it into your secret store, then revoke the old one once traffic has moved over. This pattern keeps you in control of the cutover window. ## Revoke a key In **Settings → Developers**, click **Revoke** next to the key. Revoked keys reject all subsequent requests with `401 invalid_api_key`. Revocation is immediate; we do not honor a grace period. ## Scopes Every key has full read + write access to the account that issued it. The only access boundary today is the **mode** (live vs test) — test keys can never reach into live data or vice versa. ## Where to store keys API keys are secrets. **Don't:** * Commit them to a repository. * Embed them in mobile apps or frontend JavaScript. * Share them across teams in chat or email. Instead, store them in your platform's secret manager (Vercel envs, AWS Secrets Manager, 1Password, etc.) and inject at runtime. ## Errors | Status | Code | Meaning | | ------ | ----------------- | ----------------------------------------------------------------- | | `401` | `missing_api_key` | No `Authorization` header was sent. | | `401` | `invalid_api_key` | Header present but the key doesn't match a live, non-revoked key. | | `403` | `forbidden` | The key is valid but isn't allowed to access this resource. | See the [Errors](/errors) page for the full envelope shape and the `docs_url` link pattern. # Errors Source: https://docs.sitevisit.app/errors Error envelope, status codes, and what each code means. The API returns structured JSON for every error. Look at both the HTTP status and the `code` field — the status tells you the category, the code tells you exactly what happened. ## Envelope ```json theme={null} { "error": { "code": "invalid_api_key", "message": "The API key provided is invalid or has been revoked.", "docs_url": "https://docs.sitevisit.app/errors/invalid_api_key", "request_id": "req_2026060512abc" } } ``` | Field | Notes | | ------------ | --------------------------------------------------------------------------------------- | | `code` | Stable, snake\_case identifier. Don't parse `message` — use `code` for branching logic. | | `message` | Human-readable explanation. May change over time. | | `docs_url` | Permalink to the docs page for this specific code. | | `request_id` | Echoes the `X-Request-Id` response header. Include this when contacting support. | Every response — success or error — sets `X-Request-Id`. Capture it in your client logs so we can trace failures end-to-end. ## Status codes | Status | Meaning | | ------ | ------------------------------------------------------------------------- | | `200` | Success. | | `201` | Resource created. | | `204` | Resource deleted (no body). | | `400` | Your request is malformed — bad parameter shape, unknown enum value, etc. | | `401` | Authentication failed: missing, invalid, or revoked API key. | | `402` | Plan limit hit. Response includes `upgrade_url` and `current_plan`. | | `403` | Authenticated but not permitted to access this resource. | | `404` | The resource doesn't exist, or it belongs to a different account. | | `409` | Idempotency-Key reuse while another request is still in flight. | | `422` | Idempotency-Key reused with a *different* request body. | | `429` | Rate limit exceeded. Retry with backoff. | | `500` | Something broke on our side. We get paged; you should retry with backoff. | ## Common error codes | Code | HTTP | Meaning | What to do | | ------------------------- | ----- | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | | `missing_authorization` | `401` | No `Authorization` header. | Send `Authorization: Bearer sv_live_…`. | | `invalid_api_key` | `401` | Key doesn't match a live, non-revoked key. | Check for typos; verify in **Settings → Developers**. | | `invalid_request_body` | `400` | Body isn't a JSON object on a POST/PATCH. | Ensure `Content-Type: application/json` + valid JSON. | | `invalid_parameter` | `400` | A field was the wrong shape, out of range, or missing. | Read `message` — it names the offending field. | | `invalid_status` | `400` | `status` value wasn't allowed for the resource. | See the API reference for valid enum values. | | `invalid_priority` | `400` | `priority` wasn't `LOW`, `MEDIUM`, or `HIGH`. | Use uppercase. | | `plan_limit_exceeded` | `402` | The user's plan tier is full (property / visit cap). | Surface the `upgrade_url` from the response and let the user open it. | | `not_found` | `404` | Resource doesn't exist or isn't owned by the caller. | We never reveal whether a resource exists for someone else. | | `idempotency_in_progress` | `409` | Another request with the same `Idempotency-Key` is still running. | Retry in \~1 second — the original will have committed by then. | | `idempotency_key_in_use` | `422` | The same key was reused with a different request body. | Use a fresh key for a different request. See [Idempotency](/idempotency). | | `rate_limited` | `429` | Too many requests in a short window. | Back off — see `Retry-After` header. | | `internal_error` | `500` | Unexpected server failure. | Retry with exponential backoff. Include `request_id` when reporting. | ## Plan limit response shape When a write hits a plan cap, the error envelope is extended with `upgrade_url`, `current_plan`, `used`, and `limit`: ```json theme={null} { "error": { "code": "plan_limit_exceeded", "message": "Your free plan allows 1 property; you currently have 1. Upgrade to Pro to add more.", "docs_url": "https://docs.sitevisit.app/errors/plan_limit_exceeded", "request_id": "req_abc...", "upgrade_url": "https://sitevisit.app/settings#upgrade", "current_plan": "free", "used": 1, "limit": 1 } } ``` Clients can branch on `code === "plan_limit_exceeded"` and surface a "go upgrade" CTA pointing at `upgrade_url`. MCP-driven agents do the same in natural language — the message + upgrade URL are designed to be quoted verbatim. ## Retrying `429` and `5xx` responses are safe to retry. We recommend exponential backoff starting at 1 second, capped at 30 seconds, with jitter. `4xx` errors other than `409` and `429` are deterministic — retrying without changing the request won't help. For `409 idempotency_in_progress`, a short wait + retry is the correct response. # Idempotency Source: https://docs.sitevisit.app/idempotency Safe retries on POST + PATCH + DELETE via the Idempotency-Key header. Network blips happen. Your code retries the same `POST /action-items` request twice — without idempotency that creates two duplicate items. SiteVisit mirrors Stripe's pattern: pass an `Idempotency-Key` header on any write, and we'll guarantee the request runs **at most once**, returning the original response for any retries within the next 24 hours. ## How to use it Pass a header that's unique to the *intent* of the request: ```bash theme={null} curl -X POST https://sitevisit.app/api/v1/action-items \ -H "Authorization: Bearer sv_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-42-action-1" \ -d '{"site_visit_id":"cly5...","title":"Replace bulb"}' ``` Generate keys however you like — UUIDs, hashes of inputs, monotonic counters — as long as the same logical write always uses the same key. 255 character max. ## What the server does ``` First call with key X → run the handler, store the response under (your_user_id, X) Same key X, same body → 200/201 from the stored response. No re-run. Header: Idempotent-Replay: true Same key X, DIFFERENT body → 422 idempotency_key_in_use (client bug — use a fresh key) Same key X, request still mid-flight (concurrent retries) → 409 idempotency_in_progress (retry in ~1s) After 24 hours → key is purged, treated as fresh ``` The store is **scoped to your account**, so two customers can pick the same key without colliding. ## When to use it * **Always on write retries.** If your code has any retry / circuit-breaker logic around POST/PATCH/DELETE calls, generate a key once per logical attempt and pass it on every retry. Otherwise you'll create dupes on flaky networks. * **On webhooks fanning into the API.** When your webhook handler kicks off a SiteVisit write, use the webhook's `delivery_id` (or a deterministic hash of the payload) as the key — receivers retry, but you'll only write once. * **On batch jobs.** When a nightly script reconciles state, key each write by `(job_run_id, record_id)` so a partial run can resume safely. ## When you can skip it * `GET` and other read-only requests — naturally idempotent. * `DELETE` — naturally idempotent (deleting the same id twice returns the same observable result either way). The header is accepted but doesn't do anything special. * One-off scripts you'll never retry. Even then, it's free insurance. ## Detecting a replay Successful replays come back with the `Idempotent-Replay: true` response header. Useful in logs to distinguish a network retry from a genuinely repeated user action. ``` HTTP/2 201 content-type: application/json idempotent-replay: true x-request-id: req_b9b0122805a1e7f351a8cca6 ``` ## Edge cases **Same key, different body → 422.** Means your client cached a key longer than it should have and is now sending a *different* request under it. The server can't honor "return the original response" because the original response doesn't match the new request — that would be lying. Use a fresh key. **Concurrent retries → 409.** If two requests with the same key arrive at the same instant, one wins the reservation race and runs the handler; the other returns `409 idempotency_in_progress`. A short backoff + retry will succeed (it'll then get the stored response from the winner). **Past TTL → fresh request.** After 24 hours we purge the record. If you retry with the same key 25 hours later, it'll run as a fresh request (which may create a duplicate). Either set your retry window shorter than 24h, or use longer-lived keys + a job-id scoping scheme. # Introduction Source: https://docs.sitevisit.app/introduction Programmatic access to your SiteVisit data — properties, visits, action items. SiteVisit turns a walkthrough video into a structured site report: a transcript, a summary, and a prioritized list of action items with timestamps and evidence photos. The API exposes the same data your dashboard sees, so you can pipe it into the systems your team already runs in — work-order platforms, BI tools, Slack, or your own internal apps. ## What you can do today The `v1` API is **full CRUD**. You can: **Read** properties, visits, and action items the same way the dashboard sees them. **Create** * A new property (subject to plan limits) * A site visit (manually, alongside the existing video-upload flow) * An action item on a visit (text-only — no video frame required) **Update + complete** * Patch any action item's title, description, category, location, priority, status, or assignee * Mark an action item DONE with an attribution + note + completion photo **Capture media from a phone** * Issue a short-lived QR / URL token that takes the user to a sign-in-free camera page on their phone, attaches the photo back to the right action item, and expires in 24 hours **Delete** properties, visits, or action items — cascades as expected. **Receive [webhooks](/webhooks)** for any of the 16 event types in the catalog — signed with HMAC-SHA256 using the Stripe pattern, retried with backoff if your endpoint is down, delivered to multiple endpoints in parallel. Everything is also exposed as [MCP tools](/mcp), so Claude Desktop / Cursor / your own agent can do all of the above in natural language. ## Where things live Every endpoint, parameter, and response — generated from the live OpenAPI spec. Generate a key and make your first call in under five minutes. Bearer tokens, key rotation, and how secrets are stored. Every error code, what it means, and how to fix it. ## Base URL ``` https://sitevisit.app/api/v1 ``` All endpoints are versioned under `/api/v1`. We add new fields and endpoints freely; breaking changes get a new version (`/api/v2`) and a six-month deprecation window on the old one. ## Beyond REST We also run an [MCP server](/mcp) so Claude Desktop, Cursor, and any other MCP-compatible agent can read your SiteVisit data with natural language. It wraps the same service layer — every REST endpoint above has a matching MCP tool. One key, two protocols. # MCP server Source: https://docs.sitevisit.app/mcp Connect Claude, Cursor, or any MCP-compatible AI to your SiteVisit data. SiteVisit ships an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server alongside the REST API. Point any MCP-compatible client — Claude Desktop, Cursor, Continue, your own agent — at it with one of your API keys, and the assistant can answer natural-language questions about your portfolio: > "Which property has the worst action-item backlog?" > "Summarize the open high-priority items at Sunset Apartments." > "What did the maintenance team flag during the September walkthrough at Building 4?" The MCP server is a thin wrapper around the [REST API](/api-reference) — same data, same authentication, two transports. ## Endpoint ``` https://sitevisit.app/api/mcp ``` * **Transport:** HTTP (stateless, JSON-RPC 2.0) * **Protocol version:** `2025-06-18` * **Authentication:** Bearer API key (same `sv_live_…` keys you'd use with REST — issue one at **Settings → Developers**) ## Available tools | Tool | Purpose | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `list_properties` | List properties on your account, with visit and open-item counts. | | `list_site_visits` | List site visits, filterable by property or status. | | `list_action_items` | List action items, filterable by property / visit / status / priority. | | `create_property` | Add a new property (respects plan limits — returns `plan_limit_exceeded` with an upgrade URL). | | `create_site_visit` | Manually file a visit on a property the user owns. | | `create_action_item` | File an action item on a visit. Auto-issues a [capture link](#capture-flow-attach-media-from-a-phone) by default so the user can attach evidence from their phone. | | `update_action_item` | Patch any subset of title / description / category / location / priority / status / assignee. | | `complete_action_item` | Mark DONE with attribution + note + completion photo. Auto-issues a capture link when no photo is supplied. | | `delete_action_item` | Permanently delete an item (cascades to evidence). | | `delete_site_visit` | Delete a visit (cascades to all items + evidence + videos). | | `delete_property` | Delete a property (cascades to everything below — irreversible). | Tool inputs accept cursor pagination (`limit`, `starting_after`) on the list tools, and write tools support an [Idempotency-Key](/idempotency)-equivalent pattern for safe retries. ## Capture flow: attach media from a phone The MCP tools that create or complete action items can't take a video / photo file directly (MCP protocol doesn't have file upload), but they CAN hand off to a phone. When you call `create_action_item` or `complete_action_item`, the response automatically includes: * **`capture_url`** — short URL the user opens on their phone * **`capture_qr_url`** — public PNG of a QR code that encodes the same URL * An MCP `image` content block with the QR (rendered inline by clients that support it; Claude Desktop renders the markdown image version of the same QR) When the URL is opened: * **On a phone** — goes straight to a sign-in-free camera page. Big "Take photo" button → native camera → photo uploads → attaches to the action item. * **On desktop** — shows the QR prominently, with a "scan with your phone" message. Click → scan → land on the mobile camera flow. Bridges chat-on-desktop → capture-on-phone in one step. Tokens are scoped tight: 24-hour TTL, 20-upload cap, and bound to the specific action item — a leaked URL can only ever add photos to that one item. To opt out (e.g. when you've already supplied a `completion_photo_key`), pass `include_capture_link: false`. To issue a capture link explicitly for an existing item, use the REST endpoint `POST /capture-tokens`. ## Connect Claude Desktop Claude Desktop only speaks **stdio** to MCP servers, so it can't talk to a remote HTTP endpoint directly. Use the open-source [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) bridge — Claude Desktop launches it as a local stdio server, and it proxies every call up to our HTTPS endpoint with your auth header attached. Open Claude Desktop's settings → **Developer → Edit Config**. Add a `sitevisit` block under `mcpServers`: ```json theme={null} { "mcpServers": { "sitevisit": { "command": "npx", "args": [ "-y", "mcp-remote", "https://sitevisit.app/api/mcp", "--header", "Authorization: Bearer sv_live_..." ] } } } ``` Replace `sv_live_...` with a key from **Settings → Developers**. Save the file, fully quit Claude Desktop (Cmd-Q, not just close the window — the config only loads on launch), then relaunch. The first start takes \~10 seconds while `npx` downloads `mcp-remote`; subsequent starts are instant. Open a new conversation, click the hammer icon at the bottom of the message box, and `sitevisit` should be listed with three tools. The config file lives in plain text on your laptop. Use a workstation-only key (not the same one your production servers use) and revoke it from **Settings → Developers** if the device is ever lost. ## Connect Cursor Cursor supports remote HTTP MCP servers natively. In Cursor's settings, open **MCP → Add new MCP server**, then either fill in the form fields or paste this into `~/.cursor/mcp.json`: ```json theme={null} { "mcpServers": { "sitevisit": { "url": "https://sitevisit.app/api/mcp", "headers": { "Authorization": "Bearer sv_live_..." } } } } ``` If your Cursor version doesn't accept the `url` field directly, fall back to the `mcp-remote` bridge config shown above — the same one works in any stdio-only client. ## Connect Claude on the web (claude.ai) claude.ai supports remote MCP servers directly. Go to **Settings → Connectors → Add custom connector**, then enter: * **Name:** `SiteVisit` * **Remote MCP server URL:** `https://sitevisit.app/api/mcp` * **Authentication header:** `Authorization: Bearer sv_live_...` No bridge needed — claude.ai dials the HTTPS endpoint directly. ## Build your own client Any client that speaks MCP `2025-06-18` over HTTP works. The endpoint accepts JSON-RPC 2.0 POSTs and supports: * `initialize` — handshake; returns server capabilities * `tools/list` — enumerate tools * `tools/call` — invoke a tool with arguments * `ping` — health check A bare-bones list call: ```bash theme={null} curl -X POST https://sitevisit.app/api/mcp \ -H "Authorization: Bearer sv_live_..." \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` A read tool invocation: ```bash theme={null} curl -X POST https://sitevisit.app/api/mcp \ -H "Authorization: Bearer sv_live_..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "list_action_items", "arguments": { "status": "OPEN", "priority": "HIGH", "limit": 10 } } }' ``` A write that creates an action item AND issues a capture link in the same response: ```bash theme={null} curl -X POST https://sitevisit.app/api/mcp \ -H "Authorization: Bearer sv_live_..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "create_action_item", "arguments": { "site_visit_id": "cly5...", "title": "Repaint cracked drywall by pool entrance", "priority": "MEDIUM", "location": "Pool building", "assignee": "Marc" } } }' ``` The result includes the new item's JSON plus `capture_url` and `capture_qr_url` so the caller can show the user a QR to attach a photo from their phone. A delete: ```bash theme={null} curl -X POST https://sitevisit.app/api/mcp \ -H "Authorization: Bearer sv_live_..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "delete_action_item", "arguments": { "action_item_id": "cmq0..." } } }' ``` ## Errors MCP errors come back inside the JSON-RPC envelope (`{"error": {"code": -32601, "message": "..."}}`) using standard JSON-RPC codes: | Code | Meaning | | -------- | -------------------------------------------------------------------------- | | `-32700` | Parse error — body wasn't valid JSON. | | `-32600` | Invalid request — missing `jsonrpc: "2.0"` or `method`. | | `-32601` | Method not found. | | `-32602` | Invalid params — e.g. `tools/call` without a `name`. | | `-32603` | Internal error — re-run with a fresh `id`; if persistent, contact support. | Auth failures use the REST error envelope with HTTP `401` — same shape as `/api/v1/*`. See [Errors](/errors). # Pagination Source: https://docs.sitevisit.app/pagination How to page through list endpoints with cursors. All list endpoints use **cursor-based pagination**. You don't pass page numbers — instead you pass the id of the last row you saw, and we return the next slice. ## Request parameters | Parameter | Type | Default | Max | Notes | | ---------------- | ------- | ------- | ----- | -------------------------------------------------------------------------- | | `limit` | integer | `25` | `100` | Items per page. | | `starting_after` | string | — | — | The id of the last item from the previous page. Omit on the first request. | ## Response shape ```json theme={null} { "data": [ /* … */ ], "has_more": true, "next_cursor": "cly5..." } ``` * **`data`** — the page of items, ordered most-recent-first. * **`has_more`** — `true` if more pages exist after this one. * **`next_cursor`** — pass this as `starting_after` on the next request. `null` when there is no next page. ## Walking the full list ```bash theme={null} # Page 1 curl "https://sitevisit.app/api/v1/site-visits?limit=50" \ -H "Authorization: Bearer sv_live_..." # Page 2 — use next_cursor from the response above curl "https://sitevisit.app/api/v1/site-visits?limit=50&starting_after=cly5..." \ -H "Authorization: Bearer sv_live_..." ``` Keep going until `has_more` is `false`. ## Why cursors, not offsets? Cursors don't drift when rows are inserted or deleted between requests. Offset pagination (`page=2`) can miss items or return duplicates if data changes mid-walk; cursors stay anchored to a specific row. ## Stable ordering Rows are ordered by `(created_at desc, id desc)` for most resources, and `(visit_date desc, id desc)` for site visits. The compound order makes cursoring deterministic even when many items share the same timestamp. # Quickstart Source: https://docs.sitevisit.app/quickstart Get an API key and make your first call in under five minutes. ## 1. Generate an API key Sign in to [sitevisit.app](https://sitevisit.app), open **Settings → Developers**, give the key a name (e.g. `local-dev`), and click **Generate key**. The full key is shown **exactly once**. Copy it now and store it in your secret manager — we keep only a hash, so we can't show it again. If you lose it, revoke it and generate a new one. Keys look like: ``` sv_live_e059b3544b7fa4b93c7e26933aeba781 ``` The `sv_live_` prefix identifies the environment; the random tail is hashed at rest. ## 2. Make your first call List your properties: ```bash theme={null} curl https://sitevisit.app/api/v1/properties \ -H "Authorization: Bearer sv_live_..." ``` A successful response looks like: ```json theme={null} { "data": [ { "id": "clx0...", "name": "Happy Apartments", "address": "123 Main St, Sample City", "icon_emoji": "🏢", "cover_image_url": "https://...", "is_sample": true, "visit_count": 2, "open_item_count": 3, "created_at": "2026-05-01T15:32:11.000Z", "updated_at": "2026-06-04T09:01:44.000Z" } ], "has_more": false, "next_cursor": null } ``` ## 3. Drill into a visit Grab a property `id` from the response above, then list its visits: ```bash theme={null} curl "https://sitevisit.app/api/v1/site-visits?property_id=clx0..." \ -H "Authorization: Bearer sv_live_..." ``` Then list the action items for that visit: ```bash theme={null} curl "https://sitevisit.app/api/v1/action-items?site_visit_id=cly5..." \ -H "Authorization: Bearer sv_live_..." ``` Each action item includes its priority, status, transcript quote, timestamp into the original video, and links to the evidence photos. ## 4. Create your first action item Now a write. Pick a `site_visit_id` from step 3 above and POST: ```bash theme={null} curl -X POST https://sitevisit.app/api/v1/action-items \ -H "Authorization: Bearer sv_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: my-first-write-001" \ -d '{ "site_visit_id": "cly5...", "title": "Replace bulb at front entrance", "priority": "HIGH", "location": "Front lobby" }' ``` Response (`HTTP/2 201`): ```json theme={null} { "id": "cmq0...", "siteVisitId": "cly5...", "title": "Replace bulb at front entrance", "priority": "HIGH", "status": "OPEN", "location": "Front lobby", "createdAt": "2026-06-05T13:53:53.644Z", "..." } ``` A few things to notice: * **The `Idempotency-Key` header is optional but recommended.** If your network blips and you retry, the second call returns the exact same response without creating a duplicate item. See [Idempotency](/idempotency) for the full pattern. * **Plan limits surface inline.** If you're on the Free plan and have used your visit cap, the response is `HTTP/2 402 plan_limit_exceeded` with an `upgrade_url` field — your client can branch on the code and surface the upgrade flow. * **Same data shape on read.** Run a `GET /action-items?site_visit_id=cly5...` and the new item appears in the list immediately. ## 5. Page through results Lists return up to 25 rows by default (100 max). To fetch the next page, pass the previous response's `next_cursor` as `starting_after`: ```bash theme={null} curl "https://sitevisit.app/api/v1/site-visits?starting_after=cly5...&limit=50" \ -H "Authorization: Bearer sv_live_..." ``` When `has_more` is `false`, you've reached the end. Full details in [Pagination](/pagination). ## What's next * **Browse the [API reference](/api-reference)** for every parameter and field. * **Read [Idempotency](/idempotency)** before shipping any write code to production. * **Read [Errors](/errors)** to understand what each status code means. * If you're building an AI agent or workflow, the [MCP server](/mcp) wraps these endpoints as natural-language tools — including the QR-to-phone capture flow for attaching evidence photos. # Team workspaces Source: https://docs.sitevisit.app/teams How Enterprise team accounts work in SiteVisit — invites, roles, billing, and switching between team and personal context. A **team workspace** is a shared SiteVisit account where multiple people can record visits, view reports, and edit action items against the same set of properties. Workspaces are the Enterprise plan — one Stripe subscription covers the whole team. Solo users on Free or Pro plans don't need a workspace; they own their data directly. This page covers how to set up and run a workspace. For pricing, see the [pricing section on sitevisit.app](https://sitevisit.app/#pricing). ## Set up your workspace Anyone can create a workspace by picking the **Enterprise** plan at signup: 1. Go to [sitevisit.app/start](https://sitevisit.app/start) (or hit **Subscribe** from Settings → Plan if you already have an account). 2. Choose **Enterprise** and complete checkout. 3. You become the first **admin** of the workspace — invite the rest of your team from **Settings → Team**. The base plan includes **5 seats at $499/month**. Additional seats are **$79/seat/month**. Annual saves about 16%. ## Invite teammates From **Settings → Team**, enter the teammate's email, pick a role, and send. They get an email with a one-click join link. * If they already have a SiteVisit account, accepting the invite adds the workspace to their account alongside any solo properties they already had. * If they don't have an account yet, the link takes them through signup and auto-joins them — they never see a "join later" step. Pending invites show up on the same Team page so you can resend or revoke them at any time. When an invited teammate first joins, any sample property still sitting on their personal account is removed to avoid duplicate "Happy Apartments" entries. Their real personal data, if any, is untouched. ## Roles Three roles, set per teammate when inviting (and editable later): | Role | Can do | Cannot do | | ----------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------- | | **Admin** | Everything — invite & remove teammates, change roles, manage billing, edit any property or visit | — | | **Manager** | Invite members, edit any property or visit | Manage billing, remove admins, change admin roles | | **Member** | Record visits, edit their own work | Invite, change settings, see billing | The person who first subscribed to Enterprise starts as an admin. You can have multiple admins per workspace — recommended, so billing doesn't get stuck behind one person. ## Switch between team and personal view If you belong to one or more workspaces, a small **context chip** appears in the app header. Tap it to scope the dashboard to: * A specific workspace — everything under that team * **Personal** — your solo-owned properties only (anything you created outside a workspace) * **All teams** *(default)* — everything you have access to across every workspace plus personal data The active context filters every list and detail view (properties, visits, action items) and decides where any **new** property you create lands. Solo users on Free or Pro don't see the chip — it appears once you accept your first team invitation. ## Manage billing Billing is at the workspace level — one Stripe subscription, not one per seat. Any admin can open the Stripe customer portal from **Settings → Team → Manage team billing** to: * Update the card on file * Download past invoices * Adjust seat count * Cancel the subscription Seat changes prorate automatically through Stripe. ## Rename your workspace Admins can rename the workspace from **Settings → Team**. Click the pencil next to the workspace name to open a typeahead picker. The picker searches a curated directory of multifamily operators (Smart Apartment Data + our CRM). Picking a row links the workspace to that **company record**, which lets us show portfolio context (active properties, units, HQ state) in your dashboard in future releases. If your team isn't in the directory, type your preferred name and choose **Use this as a custom name** — it sets the workspace name without a directory link. ## Data ownership Properties and site visits inside a workspace are accessible to every member of that workspace, subject to their role. When a member is removed (or leaves), the data stays with the workspace — not with the individual. Personal (solo-owned) properties stay attached to the user who created them and are never visible to teammates. The API mirrors this model: requests using an API key issued by a workspace admin see the workspace's data; requests using a personal user's key see only that user's data. See the [Authentication guide](/authentication) for how keys are scoped. ## Next steps Free, Pro, and Enterprise side by side. How API keys are scoped to a user or workspace. # Versioning Source: https://docs.sitevisit.app/versioning How we evolve the API without breaking your integration. ## The contract The API is versioned in the URL path: `/api/v1/…`. As long as you stay on `v1`, we guarantee: * We won't remove endpoints. * We won't remove or rename existing response fields. * We won't change the type of an existing field. * We won't tighten validation in a way that rejects requests that worked before. We **will** add things — new endpoints, new optional fields on responses, new optional query parameters. Your client should ignore unknown fields. ## When we cut `v2` If we ever need a breaking change, we publish it on `/api/v2`. `v1` stays online for **at least six months** after `v2` ships, with deprecation warnings in the `Sunset` response header and an email to every account that has made a `v1` call in the previous 30 days. ## Beta endpoints Endpoints we want feedback on before promoting to stable live under `/api/v1/…` paths but are marked **Beta** in the [API reference](/api-reference). Beta endpoints may change shape; pin to a specific date or watch the changelog if you depend on them. ## Changelog The changelog lives at [docs.sitevisit.app/changelog](https://docs.sitevisit.app/changelog) (coming soon). For now, subscribe to release notes by emailing `dev@sitevisit.app`. # Webhooks Source: https://docs.sitevisit.app/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 ``` **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. * **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/ \ -H "Authorization: Bearer sv_live_..." \ -H "Content-Type: application/json" \ -d '{"disabled": true}' # Delete curl -X DELETE https://sitevisit.app/api/v1/webhook-endpoints/ \ -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.