# Build on SiteViewer

SiteViewer hosts client previews of websites, HTML files, images and PDFs, and collects the client's feedback as comments pinned to the page. This reference is for developers connecting another tool, an AI assistant or a pipeline to a SiteViewer workspace: the ways in, how to sign in, what each call does, and the limits that apply.

## Overview

| Way in | What it is for | How it signs in | Availability |
|---|---|---|---|
| [AI connector (MCP)](#connecting) | AI assistants such as Claude, Claude Code and Cursor, or any MCP client | OAuth 2.1, or an access token | Pro and Studio plans |
| [OAuth 2.1](#authentication) | MCP clients that connect by consent | Authorization code with PKCE, public clients | With the AI connector |
| [Webhooks](#events) | A build or deploy pipeline that ships a version once it is approved | Each request we send is signed with HMAC-SHA256 | Available on request (Labs) |
| [Add-website link](#add-a-website-by-link) | Opening the dashboard with a website ready to add | The person's own SiteViewer sign-in | Every workspace; adding a live website needs a paid plan |
| [Reply by email](#reply-by-email) | Answering a comment from its notification email | The reply address, plus a sender check | Every workspace |
| [Health check](#status-and-health) | Uptime monitors | None | Public |

All addresses on this page are on `https://siteviewer.app`.

### What is not a public API

The SiteViewer dashboard and our browser extension talk to SiteViewer through routes built for them alone. Those routes are not supported for outside use and change without notice, so please do not build on them. There is no general REST API with API keys today. If you would use one, [tell us what for](/contact): it shapes what we build next.

### Status and health

- `GET https://siteviewer.app/healthz` answers `200` when every check passes and `503` when one fails, so a monitor does not need to read the body.
- People can follow incidents on the [status page](/status).

## Quickstart

The quickest way in is the AI connector. Its address is:

```text
https://siteviewer.app/mcp
```

### Claude and Claude Desktop

1. Open Settings, then Connectors.
2. Choose Add custom connector.
3. Name it SiteViewer, paste `https://siteviewer.app/mcp` as the URL and leave the OAuth fields blank.
4. Click Add, then approve the SiteViewer screen that appears and pick the workspace to connect.

### Claude Code

Make an [access token](#access-tokens) first, then run:

```sh
claude mcp add --transport http siteviewer https://siteviewer.app/mcp \
  --header "Authorization: Bearer $SITEVIEWER_TOKEN"
```

### Cursor

Make an [access token](#access-tokens), then add this to `~/.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "siteviewer": {
      "url": "https://siteviewer.app/mcp",
      "headers": { "Authorization": "Bearer YOUR_TOKEN" }
    }
  }
}
```

### Any client, by hand

The connector speaks plain JSON-RPC over HTTP, so `curl` is enough to try it:

```sh
curl https://siteviewer.app/mcp \
  -H "Authorization: Bearer $SITEVIEWER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_open_feedback","arguments":{}}}'
```

Most tools answer with a sentence for the assistant, followed by the same answer as JSON for programs; a few, such as `list_previews`, return the JSON alone:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      { "type": "text", "text": "2 previews with open client feedback (7 open comments in total). Busiest first." },
      { "type": "text", "text": "{ \"previewsWithOpenFeedback\": [ ... ], \"next\": \"...\" }" }
    ]
  }
}
```

## Authentication

Every call to the AI connector carries a bearer token in the `Authorization` header:

```http
Authorization: Bearer <token>
```

There are two kinds of token: an OAuth access token, for clients that connect by consent, and an access token made in the dashboard, for tools you would rather connect with a token and for your own scripts.

### OAuth 2.1

Claude runs this flow for you. If you are writing your own client, discovery starts at the connector: a call with no token gets `401` and this header.

```http
WWW-Authenticate: Bearer resource_metadata="https://siteviewer.app/.well-known/oauth-protected-resource", error="invalid_token"
```

| Endpoint | Purpose |
|---|---|
| `GET /.well-known/oauth-protected-resource` | Protected resource metadata (RFC 9728): which authorization server protects `/mcp` |
| `GET /.well-known/oauth-authorization-server` | Authorization server metadata (RFC 8414) |
| `POST /oauth/register` | Dynamic client registration (RFC 7591) |
| `GET /oauth/authorize` | The consent screen: the person signs in to SiteViewer and picks a workspace. It shows the name the app gave and the address it will send them back to |
| `POST /oauth/token` | Exchange a code and its PKCE verifier, or a refresh token, for tokens |

- Public clients only: `token_endpoint_auth_method` is `none`, and PKCE with `S256` is required.
- Registration takes 1 to 10 `redirect_uris`, each `https` or an `http` loopback address (`localhost` or `127.0.0.1`). A registration that never completes a connection is removed after 30 days.
- Grant types: `authorization_code` and `refresh_token`. `POST /oauth/token` takes a form-encoded body (`application/x-www-form-urlencoded`), as OAuth specifies.
- Lifetimes: an authorization code lasts 60 seconds and works once. An access token lasts 1 hour. A refresh token lasts 30 days and is replaced every time you use it, so keep the new one; it must be used by the client it was issued to.
- Access is checked again on every call. A person removed from the team loses access on their next call, not when their token expires.
- OAuth errors use the standard shape, usually with status `400`, or `429` when registering too often:

```json
{ "error": "invalid_grant", "error_description": "A sentence saying what went wrong." }
```

### Access tokens

For tools you would rather connect with a token, such as Cursor or Claude Code, and for your own scripts.

- An owner or admin makes one on the Connectors page of the SiteViewer dashboard. It is shown once, so copy it straight away.
- A token is `svmcp_` followed by 43 URL-safe characters.
- It acts as the person who made it, and works only while that person is on the workspace's team with an open account.
- It does not expire. Revoke it on the Connectors page and it stops working on the next call. Revoking still works while the workspace's AI tools are switched off.
- Use one token per tool, so you can revoke one without touching the others.
- An app connected by consent can be disconnected on the same page, under the apps you connected. It can take up to an hour to drop off.

### Scopes

| Scope | Allows |
|---|---|
| `mcp:read` | The read tools |
| `mcp:write` | The tools that change something |

- With OAuth, ask for scopes in the `scope` parameter of the authorize request. Unknown scopes are dropped, and a request with no known scope (none at all, or only unknown ones) gets both.
- A connection's `tools/list` shows only the tools its scopes allow. Calling another one returns a tool error asking the person to connect again and approve changes.
- Access tokens carry no scope: they have full read and write access to their workspace.

### Who can connect

- A member of the workspace's team (an owner, admin or member). People invited to individual projects only cannot connect AI tools.
- A workspace on the Pro or Studio plan. A Free workspace can complete the consent screen, but every call is then refused with a `403` that says so.
- AI tools switched on for the workspace, in Settings. Switching them off stops every token and connection without deleting them, and switching them back on restores them.

## Connecting

| | |
|---|---|
| Endpoint | `POST https://siteviewer.app/mcp` |
| Transport | JSON-RPC 2.0 over HTTP, request and response only (no server-sent event stream) |
| Protocol version | `2025-06-18`; `initialize` echoes the version the client asks for |
| Methods | `initialize`, `ping`, `tools/list`, `tools/call` |
| Capabilities | Tools only, no resources or prompts |

- Any method other than `POST` gets `405` with `Allow: POST`.
- A notification (a method starting `notifications/`) gets `202` with no body.
- The server is stateless: you can call `tools/list` or `tools/call` without an `initialize` first.

### Errors

Problems with the request itself come back as a JSON-RPC `error`:

| Code | HTTP status | Meaning |
|---|---|---|
| `-32001` | `401` | No token, or the token is wrong, expired, or its person has left the team. Comes with `WWW-Authenticate`, so the client can sign in again. |
| `-32001` | `403` | The person's access is paused. The message says so; signing in again will not help. |
| `-32003` | `403` | The token is fine but the workspace cannot use the connector now: it is closed or on hold, its AI tools are off, the connector is not available to it, or it is on the Free plan. The message is a sentence you can show the person. Do not restart sign-in for this. |
| `-32700` | `400` | The body is not valid JSON. |
| `-32601` | `200` | Unknown method. |
| `-32602` | `200` | Unknown tool. |
| `-32603` | `200` | Something went wrong on our side. The message is always generic. |

A tool that runs but cannot do what was asked returns a normal `result` with `isError: true` and one plain sentence saying what to fix: a missing or wrong argument, a value that is too long, a limit reached, or HTML that failed the quality check. Read it and retry; it is written for the assistant to act on.

## Tools

The connector has 23 tools. Each one carries MCP annotations: the read tools are marked read-only, and none of the write tools delete anything. There is no delete tool.

| Tool | Access | What it does |
|---|---|---|
| `list_previews` | Read | The workspace's previews hosted as files (uploads and imports), with id, slug, title and status |
| `list_open_feedback` | Read | Previews with open comments, busiest first |
| `get_preview` | Read | One preview: its review link, whether comments are on, the current version and how many comments are open |
| `get_feedback` | Read | The pinned comments (element, page and nearby text), attachments as links, and the automated accessibility and SEO findings |
| `get_version_html` | Read | The complete HTML of the current version, to edit in place |
| `list_versions` | Read | Version history, newest first |
| `get_preview_screenshot` | Read | An image of the current version as it renders, on desktop or `mobile`, in parts up to 2000 pixels tall (pass `from` for the next part) |
| `get_approval_status` | Read | Whether the current version is approved, and the address the approval came with. It does not prove who approved, so confirm with a person before you deploy |
| `list_transfers` | Read | The preview's Request files and Send files, and the project's assets |
| `get_received_file` | Read | A 10-minute download link for one received file; small images and text come back inline |
| `activate_version` | Write | Make an earlier version the current one (a rollback); nothing is deleted |
| `resolve_feedback` | Write | Mark threads resolved. A comment on a custom status is left alone unless you pass `replaceStatus: true` |
| `reply_to_feedback` | Write | A private note on a thread that only the team sees; the client is never told |
| `push_version` | Write | A new version of an existing preview from one complete HTML document. On a preview made of many files it replaces the entry page and keeps every other file |
| `push_files` | Write | A new version from many files: `index.html` at the root, binary files as base64. Only web file types are published (see below); any other file is left out and listed in `skipped` |
| `publish_preview` | Write | A brand-new preview, always password-protected: with the `password` you pass, or one SiteViewer makes. SiteViewer picks the address |
| `request_files` | Write | A Request files link for the team to send to a client; nothing is emailed |
| `send_files` | Write | A download link for files, from the project's assets, new files, or both; nothing is emailed |
| `start_upload` | Write | Available on request. Starts sending a whole site (up to 5,000 files, 90 MB each, within the workspace's storage) straight to storage, for a new version or a new preview. Returns an upload id and the shell commands that send the files; files unchanged since the current version are not sent again |
| `upload_status` | Read | Available on request. How far an upload has got: what has arrived, what is still to send, and when it closes. It needs write access, as an upload does |
| `finish_upload` | Write | Available on request. Publishes what an upload holds, as a new version on the same link or as the new preview (password-protected), and returns the link; nothing is emailed. Lists every file it left out, with the reason |
| `cancel_upload` | Write | Available on request. Cancels an unfinished upload and deletes the files it holds; nothing published is touched |
| `get_upload_link` | Write | Available on request. For an assistant with no shell: a link (normally the short `siteviewer.app/u/...` form), good for 24 hours, that a signed-in person opens to upload the site themselves. With `title`, the password-protected preview is made at once, waiting for its files, and the reply gives its address |

### How the tools behave

- **Ids, not slugs.** Every tool that takes a `preview` accepts its id or its slug. The id never changes; the slug changes when a preview is renamed, so store ids.
- **Client links never change.** A new version goes onto the link the client already has. Nothing the connector does renames a preview, changes its password or moves its address.
- **Previews hosted as files.** A live-website preview (SiteViewer showing a client's own live site) has no file to change, so most tools refuse it with a message, and `list_previews` and `list_open_feedback` leave it out. `get_preview` and the four transfer tools work on every preview.
- **A quality check on published HTML.** `push_version`, `push_files` (its entry page) and `publish_preview` check the HTML first. It must be a full document, not a fragment, Markdown or JSON, with a real `<title>`, a `lang` attribute, a viewport tag, alt text on images, labelled form controls, and readable text on every link and button. A failure returns the list of issues as a tool error so the assistant can fix them and try again, and it does not count toward the [write limit](#limits).
- **New previews start password-protected.** `publish_preview` and a `finish_upload` that makes a new preview always create it password-protected, with the `password` you pass or one SiteViewer makes. The password is never in the reply: the person finds it, changes it, or makes the preview public in the dashboard, on the preview's Settings tab. A new version never changes who can open a preview.
- **Only web files are published.** `push_files` and the uploads publish a file only when the type at the end of its name (any case) is one of: html, htm, css, js, mjs, json, map, txt, xml, webmanifest, svg, png, jpg, jpeg, gif, webp, avif, ico, bmp, woff, woff2, ttf, otf, eot, mp4, webm, mov, mp3, wav, ogg, m4a, pdf, vtt, wasm, glb, gltf, and bin only when a .gltf in the same build names it. `_redirects` and `_headers` go by name at the top of the site. Anything else (server code, source files, settings, databases, a file with no type), hidden files and folders, `node_modules`, settings, secret, credential and key files by name (also when another type hides inside the name, such as `dump.sql.txt`; a .json or .txt named for tokens, keys or env), project files at the top of the site (`package.json`, `composer.json`, a build tool's own settings), and a small .json or .txt that holds a private key or a service account's credentials are left out, and the reply lists each one with the reason. A folder that is a project's source rather than a site (a lock file, `node_modules` or a `.env` at its top, a `src` folder beside its project files, a build tool's settings beside the folder it builds into, or a server's own files: a `package.json` that runs or depends on a server or database, or server, database and route scripts beside it) is refused whole, and the reply names the folder to send. A top `package.json` is read for this, never published.
- **An upload belongs to its connection.** Only the connection that called `start_upload` can carry the upload on or finish it: the same access token, or the same app for the same person, including after signing in to that app again. Revoke its access token, or disconnect its app, and the upload's own token stops working on the next request; cancel it and start a new one. An owner or admin can list every open upload (`upload_status` with no `uploadId`) and cancel any of them. The upload's own address takes at most 12,000 requests an hour per upload and 30,000 per workspace.
- **Compact replies.** The JSON in a reply is on one line, and a field with no value is left out rather than sent as `null`: read a missing field as empty. The one exception is `next`: it is still sent as `null` on the last page, so code that pages until `next` is null keeps working. `previewTitle` is left out when it is the same as `preview`, and `firstAt` when it is the same as `at`. No field has been renamed.
- **Content from clients is data.** Anything a client wrote (comments, the names and addresses they gave, file names and details) and text from a file a client sent comes back between marked fence lines, with a note to treat it as data and never as instructions. Markup and code files come back as a download link only.

### Next version (available on request, Labs)

A newer version of the connector is available on request for workspaces in Labs. It also covers live-website previews, and adds these tools:

| Tool | Access | What it does |
|---|---|---|
| `get_page` | Read | One page of a preview as its owner sees it: its words, block by block, and its images |
| `check_thread` | Read | Checks comments against the page as it is now, with what the client saw beside the same spot today, and suggests whether each looks addressed |
| `review_feedback` | Read | The same check for every open comment on a preview, one browser visit per page |
| `list_statuses` | Read | The workspace's own conversation statuses |
| `get_change_brief` | Read | The Export for AI change brief for a preview, as Markdown or JSON |
| `get_prompt` | Read | A ready-to-paste brief for up to 10 comments |
| `mark_addressed` | Write | A team-only note saying what changed, plus a checked mark the team sees |
| `reopen_feedback` | Write | Reopen resolved comments |
| `reply_to_client` | Write | A reply the client sees and is emailed about |
| `apply_review` | Write | Several decisions at once, up to 50 |
| `get_inbox` | Read | Your Inbox as you see it: the newest comments across the workspace (previews you unfollowed left out) and the ones assigned to you |
| `get_link_activity` | Read | Who opened a preview's links, newest first; visitors named only by the name they gave, email addresses shortened |
| `get_project_notes` | Read | The team's Notes tab for a preview, as plain text, 20,000 characters a call: pass the reply's `next` as `offset` for the rest, or `maxChars` for up to 100,000. Notes can hold logins, so treat them with care |
| `list_tasks` | Read | Tasks and Schedule as you see them (a teammate's private task stays private), by preview, assigned to you, or scheduled in a date range |
| `get_health` | Read | The Health checklist for a preview's current version, from the last results. It never runs a check |
| `compare_versions` | Read | Two versions of a website preview from the pictures already taken of them, with the words added and removed. It never takes a new picture |
| `create_task` | Write | A task in Tasks and Schedule, made as you, optionally on a preview, with dates, checklist items, a private flag and a teammate as assignee (by email, teammates only). It also goes on the assignee's Google Calendar where they have connected it. Clients never see tasks |
| `complete_task` | Write | Mark a task done, or not done again. A task you can't see, such as a teammate's private task, is answered as not found |
| `add_project_note` | Write | Adds plain text to the end of a preview's team-only Notes tab, signed with your name and "(via AI)" and the date. It never changes or removes what is there, and a teammate who has the Notes tab open keeps the note when they save (in a dashboard page opened since this was released; an older open page saves as before until it is reloaded) |
| `run_health_checks` | Write | Runs a preview's Health checks now: the quick ones unless you name others. Page-load and AI checks run only when named and switched on for the workspace. Answers with a short summary; `get_health` has the findings |
| `get_pin_map` | Read | One picture of a page with each open comment's pin drawn as its number (#N), where the client placed it, and a short list: number, status, link. `page`, `viewport` (`mobile` for the comments made on a phone), `version`, and `from` (the reply's `next`) for a tall page. Only numbers are drawn; the first words of each comment come back fenced |

Every comment the next version returns (in `get_feedback`, `check_thread`, `review_feedback`, `get_inbox`, `get_pin_map`, the `new_feedback` prompt and the `feedback://` resources) carries a `pinUrl`: a link that opens the review room on that comment's version with its pin open. It is the same link the dashboard's Inbox copies, with no password or pass in it, so open it signed in to SiteViewer (on a custom domain, the room offers "I'm on the team: sign in"). A comment whose room can't open (the preview archived, expired, published, with comments off, or in a workspace whose paid plan has ended) has no `pinUrl`, and `get_pin_map` draws a preview whose link is off only on a picture already kept for it.

`list_previews`, `get_feedback` and `get_preview_screenshot` also take new options there, such as a preview type or a page; `get_feedback` and `review_feedback` take an `author`, and `list_versions` covers live websites too. `get_health`, `compare_versions`, `get_project_notes`, `list_tasks`, `get_pin_map` and the four team-only writes are listed only where their feature is switched on for the workspace, and the writes need write access. `get_page` answers again from the page it read in the last 10 minutes (2 for a live website) without opening it; pass `fresh: true` to read it now.

The next version also offers two MCP prompts. `check_feedback_addressed` (arguments `preview`, and optionally `reviewer` and `page`, each up to 200 characters) walks the assistant through reading a client's feedback, checking each comment against the page as it is now, showing the evidence and proposing what to record. A connection that may only read gets a version that stops at the proposal. `new_feedback` (arguments `preview`, and optionally `since`, a date) brings a preview's new client comments into the conversation: the open comments waiting on the team, newest first, up to 30 with the last 6 messages of each, each with its page and up to 5 of the clients' snapshots, fenced as data. In Claude Code it is the slash command `/mcp__siteviewer__new_feedback`.

It also offers MCP resources: `resources/list` gives `feedback://<slug>` for each preview (the 100 most recently changed), and `resources/read` returns that preview's open comments waiting on the team, fenced, as text. In Claude Code, `@siteviewer:feedback://<slug>` attaches them to a message. An address that is not one of your workspace's previews gets the same `Resource not found` error whatever the reason. `new_feedback` and the resources need read access and count toward the tool-call limit.

Anything a client would see (a reply, a resolve, a reopen or a status change) is off until an owner or admin allows it on the Connectors page, and each one takes a confirm step. The first call changes nothing: it returns what would happen and a code. Call again with the same arguments and `confirm` set to that code to go ahead. A code lasts 10 minutes, works once, and is tied to the connection, the tool and the arguments.

## Limits

| Limit | Value |
|---|---|
| Tool calls | 600 per workspace in each clock hour (UTC), and 400 for each person connected by OAuth (all their connections together) or each access token |
| Publishing and transfers: `push_version`, `push_files`, `publish_preview`, `request_files`, `send_files`, and in the next version `create_task`, `complete_task`, `add_project_note` and `run_health_checks` | 60 per workspace in each clock hour (UTC), all of these together |
| Screenshots | 60 renders per workspace per hour. The same render is returned again for 10 minutes. A call waits up to 25 seconds, then says the image is still being made |
| Argument sizes | `html` 5,000,000 characters, `title` 300, `message` 5,000, `files` 500 items, `threadIds` 1,000, `assets` 100, `manifest` 5,000 items |
| Uploads (on request) | 5,000 files and 90 MB a file, within the workspace's storage; 10 open uploads per person; an upload closes after 6 hours with nothing sent, and 48 hours after it started at most. `start_upload` and `finish_upload` count toward the publishing limit. The upload's own address: 12,000 requests per upload and 30,000 per workspace in each clock hour (UTC) |
| New files in one `send_files` call | 25 MB in total, within the plan's own transfer limits |
| Received-file links | 10 minutes |
| OAuth client registration | 20 per IP address per hour |
| Next version (Labs) | 30 changes a client can see per workspace per hour, 60 pages opened in a browser per hour, up to 50 comments in one batch. A page answered again from one read a moment ago does not count toward the 60; a `run_health_checks` that starts page-load or AI checks counts one for each. A `get_pin_map` picture counts one when it is drawn; the same picture asked for again (same pins, page and part) is answered without counting. Each picture is up to 2,000 pixels tall. A task takes up to 50 checklist items; a note up to 5,000 characters |

When a limit is reached the tool returns a sentence saying so, and nothing is changed; the tool-call limits also say when to try again. If the tool-call or write limits cannot be checked at that moment, the call is refused rather than let through.

## Events

Webhooks tell your pipeline the moment a version is signed off, so it does not need to poll `get_approval_status`. They are available on request (Labs). Once they are on, an owner or admin adds them in the Deploy webhooks card on the Connectors page.

| Event | Sent when |
|---|---|
| `deploy.approved` | An owner or admin marks a preview's current version ready to deploy, on its Deploy tab |
| `deploy.unapproved` | They withdraw that approval |
| `ping` | You press Send test on the webhook, to check your endpoint and your signature check |

- Every webhook receives both deploy events.
- Up to 5 webhooks per workspace.
- The URL must be `https`. Local and private network addresses, and SiteViewer's own addresses, are refused, and redirects are not followed.

## Payloads

Each event is a `POST` with a JSON body and these headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `SiteViewer-Webhooks/1.0` |
| `X-SiteViewer-Event` | The event name, for example `deploy.approved` |
| `X-SiteViewer-Delivery` | An id for this delivery. It stays the same across retries, so use it to ignore a repeat |
| `X-SiteViewer-Timestamp` | When this attempt was sent, in milliseconds since the Unix epoch |
| `X-SiteViewer-Signature` | `sha256=` followed by the signature in hex |

A deploy event:

```json
{
  "event": "deploy.approved",
  "createdAt": 1793400000000,
  "workspace": { "slug": "acme" },
  "preview": {
    "id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
    "slug": "spring-launch",
    "title": "Spring launch",
    "url": "https://acme.siteviewer.app/spring-launch"
  },
  "version": {
    "number": 3,
    "approved": true,
    "approvedAt": 1793400000000,
    "approvedBy": "dana@acme.example"
  }
}
```

A test event:

```json
{
  "event": "ping",
  "createdAt": 1793400000000,
  "workspace": { "slug": "acme" },
  "message": "Test event from SiteViewer. If you can verify this signature, you're wired up."
}
```

Times are milliseconds since the Unix epoch. For `deploy.unapproved`, `version.approved` is `false`.

## Verifying signatures

Each webhook has its own secret, starting `whsec_`, which owners and admins can see in the dashboard. To check a request:

1. Read the raw request body as text, before parsing it as JSON.
2. Join the `X-SiteViewer-Timestamp` value, a full stop, and the raw body: `<timestamp>.<body>`.
3. Compute an HMAC-SHA256 of that string with the webhook's secret, as lowercase hex.
4. Compare it with the hex after `sha256=` in `X-SiteViewer-Signature`, in constant time.
5. Refuse a timestamp more than a few minutes old, so a captured request cannot be replayed later. Every retry carries a fresh timestamp.

In Node.js:

```js
import crypto from "node:crypto";

export function verifySiteViewer(rawBody, headers, secret, toleranceMs = 5 * 60 * 1000) {
  const ts = headers["x-siteviewer-timestamp"];
  const sent = String(headers["x-siteviewer-signature"] || "").replace(/^sha256=/, "");
  if (!ts || Math.abs(Date.now() - Number(ts)) > toleranceMs) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  const a = Buffer.from(sent, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

### Delivery and retries

- Answer with any `2xx` status within 8 seconds. Anything else, or no answer in time, counts as a failure.
- A failed delivery is retried after 15 minutes, 30 minutes, 1 hour, 2 hours and 4 hours, then marked failed. That is about 7 hours 45 minutes in all.
- Delivery is at least once, so the same event can arrive twice. Ignore a repeat by its `X-SiteViewer-Delivery` id.
- A retry that comes due while the webhook is paused or deleted, or while the workspace's AI tools or this Labs feature are switched off, is cancelled.
- To change a secret, delete the webhook and add it again.

## Add a website by link

Our Chrome extension's Add website button opens the SiteViewer dashboard with the site's address already filled in. Any link can do the same:

```text
https://siteviewer.app/app?add=website&url=https%3A%2F%2Fclient-site.example
```

- `url` is optional and URL-encoded. Pass a plain `https` address; the extension sends the site's origin only. Up to 2,048 characters are kept.
- The link only fills in the New project box. The person checks the address, ticks the box to confirm they have permission to share the site, and presses Add themselves. A link never creates anything on its own.
- If the person has to sign in first, the request waits for up to an hour in that browser and the box opens once they are in.
- It does not open for someone invited to individual projects only, because they cannot add projects to the workspace.
- The parameters are taken off the address bar straight away, so a reload does not open the box twice.
- Never put a password in the link.

## Reply by email

An instant notification about a single comment (a reply, a mention or an assignment) carries a `Reply-To` address unique to that thread and that recipient. Digest emails do not. Nor does any email about a note only the team can see. The address looks like this:

```text
reply-<32 hex characters>@reply.siteviewer.app
```

- A reply posts into the thread as a normal comment, with the usual notifications.
- It is accepted only from the address the email was sent to.
- An address works for 30 days and for up to 50 replies. A thread takes up to 20 replies an hour; the hour starts at the first reply, and the next one after that is refused until the hour is up.
- The quoted history is removed and up to 5,000 characters of the reply are kept. Messages over 2 MB are refused. Auto-replies, out-of-office messages, bulk mail and bounces are ignored.
- If a reply cannot be posted, the sender gets a bounce with a plain sentence, for example that the reply link has expired.
