> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trysight.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive outbound webhook events when the Outreach Agent acts on a conversation.

## What are outreach webhooks?

Outreach webhooks let your platform receive real-time notifications when the Sight AI Outreach Agent acts on a conversation you pushed in via the [Ingestion API](/developers/api-reference/outreach-inbox) or that arrived via email forwarding. Instead of polling the inbox, Sight AI POSTs an event to your URL the moment something happens.

## When webhooks fire

Sight AI emits events at these points in the outreach lifecycle:

| Event                                  | When it fires                                                                                                        |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `outreach.opportunity_created`         | A new outreach opportunity row is created (from a forwarded pitch, a direct inbound, or the API ingestion endpoint). |
| `outreach.message_received`            | An inbound message lands in the inbox (forward, direct inbound, or reply on an existing thread).                     |
| `outreach.reply_sent`                  | The agent sends a reply to the prospect.                                                                             |
| `outreach.conversation_status_changed` | A conversation's status transitions to a terminal state (`resolved`, `declined`, `awaiting_human`).                  |
| `outreach.contact_suppressed`          | A contact is added to the team-wide suppression list (opt-out, bounce, complaint, or manual).                        |

## Subscribing

Webhook subscriptions are managed via the REST API after a platform completes the OAuth flow. There is no manual dashboard form — a platform registers an OAuth client, the user authorizes it, and the platform creates its subscription programmatically.

### The OAuth flow

1. **Register an OAuth client** (one-time, developer setup). In **Integrations → Sight AI API → OAuth apps**, create a client with your app's name and HTTPS redirect URI. For a server-side app, check **Server-side app (issue a client secret)** to register a confidential client — copy the `client_id` and the `client_secret` (`sai_ocsec_…`) shown once. For a desktop/SPA client, leave it unchecked (public client, PKCE only). See [Authentication → Public vs. confidential clients](/developers/authentication#public-vs-confidential-clients).

2. **Redirect the user to authorize.** Your app redirects the user to Sight AI's authorize endpoint with the scopes it needs (`webhooks:write`, `inbox:write`, etc.) and PKCE:

   ```
   GET https://app.trysight.ai/oauth/authorize
     ?response_type=code
     &client_id=YOUR_CLIENT_ID
     &redirect_uri=https://your-app.com/oauth/callback
     &scope=webhooks:write webhooks:read inbox:write
     &code_challenge=PKCE_CHALLENGE
     &code_challenge_method=S256
     &state=RANDOM_STATE
   ```

3. **User consents.** The user picks the site(s) to grant access to and approves the requested scopes on Sight AI's consent screen.

4. **Exchange the code for an access token.** Your app exchanges the authorization code for a `sai_oauth_*` access token. Confidential clients also send their `client_secret`; public clients rely on PKCE alone:

   ```bash theme={null}
   # Confidential client (client_secret_post) — send the secret in the body
   curl -X POST https://app.trysight.ai/oauth/token \
     -H "Content-Type: application/json" \
     -d '{
       "grant_type": "authorization_code",
       "code": "AUTH_CODE",
       "client_id": "YOUR_CLIENT_ID",
       "client_secret": "sai_ocsec_YOUR_SECRET",
       "redirect_uri": "https://your-app.com/oauth/callback",
       "code_verifier": "PKCE_VERIFIER"
     }'
   ```

5. **Create the webhook subscription.** Your app uses the access token to create its subscription, selecting the event types it wants:

   ```bash theme={null}
   curl -X POST https://app.trysight.ai/api/v1/sites/SITE_ID/outreach/webhooks \
     -H "Authorization: Bearer sai_oauth_..." \
     -H "Content-Type: application/json" \
     -d '{
       "label": "CRM — inbound handler",
       "url": "https://your-app.com/webhooks/sightai",
       "eventTypes": ["outreach.reply_sent", "outreach.conversation_status_changed"]
     }'
   ```

   The response includes the **signing secret** (shown once — store it securely). See [Outreach Webhooks API](/developers/api-reference/outreach-webhooks) for the full request/response reference.

You can create multiple subscriptions per site (e.g. one for your CRM, one for your analytics pipeline) and multiple event types per subscription. To revoke a platform's access, delete its OAuth client in **Integrations → Sight AI API → OAuth apps** — this cascades to delete all webhook subscriptions that client created.

<Note>
  Event types are selected when you create the subscription, not as OAuth scopes. The <code>webhooks:write</code> scope grants the <em>capability</em> to create subscriptions; the <code>eventTypes</code> array in the create request chooses which events you receive.
</Note>

## Verifying the signature

Every webhook POST includes a `SightAI-Signature` header in the Stripe-style format:

```
t=<unix_timestamp>,v1=<hex_signature>
```

The signature is an HMAC-SHA256 over `${timestamp}.${raw_request_body}` using your subscription's signing secret.

### Verify in Node.js

```javascript theme={null}
import crypto from 'node:crypto';

function verifySignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => {
      const [k, v] = p.split('=');
      return [k, v];
    }),
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1;
  if (!timestamp || !signature) return false;

  // Reject replays older than the tolerance
  const age = Math.floor(Date.now() / 1000) - timestamp;
  if (age > toleranceSeconds) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature),
  );
}

// In your handler:
// const rawBody = await request.text(); // IMPORTANT: use the raw body, not parsed JSON
// const sig = request.headers.get('SightAI-Signature');
// if (!verifySignature(rawBody, sig, process.env.SIGHTAI_WEBHOOK_SECRET)) {
//   return new Response('Invalid signature', { status: 401 });
// }
```

### Verify in Python

```python theme={null}
import hmac
import hashlib
import time

def verify_signature(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split('=', 1) for p in signature_header.split(','))
    timestamp = int(parts.get('t', 0))
    signature = parts.get('v1', '')
    if not timestamp or not signature:
        return False

    if int(time.time()) - timestamp > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)
```

<Note>
  Always verify the signature **before** processing the payload. An unverified webhook could be a forged request from anyone who knows your endpoint URL.
</Note>

## Event payload

All events share the same envelope shape:

```json theme={null}
{
  "id": "outreach.reply_sent:1719655200000:abc123",
  "type": "outreach.reply_sent",
  "siteId": "site_abc123",
  "teamId": "team_xyz",
  "timestamp": 1719655200000,
  "data": { ... }
}
```

The `data` object is event-specific:

### `outreach.opportunity_created`

```json theme={null}
{
  "data": {
    "opportunityId": "opp_001",
    "conversationId": "conv_xyz",
    "entrySource": "forwarded_actionable"
  }
}
```

### `outreach.message_received`

```json theme={null}
{
  "data": {
    "conversationId": "conv_xyz",
    "opportunityId": "opp_001",
    "messageId": "msg_abc",
    "entrySource": "forwarded_actionable"
  }
}
```

### `outreach.reply_sent`

```json theme={null}
{
  "data": {
    "conversationId": "conv_xyz",
    "messageId": "msg_outbound_001",
    "mailgunMessageId": "<20260629...@mail-sightai.com>",
    "triage": "accept",
    "autoResolved": false,
    "sendOutcome": "sent"
  }
}
```

### `outreach.conversation_status_changed`

```json theme={null}
{
  "data": {
    "conversationId": "conv_xyz",
    "oldStatus": "open",
    "newStatus": "resolved",
    "resolvedBy": "agent",
    "resolutionOutcome": "accept"
  }
}
```

### `outreach.contact_suppressed`

```json theme={null}
{
  "data": {
    "email": "prospect@example.com",
    "reason": "opted_out",
    "source": "inbound_classifier",
    "upgraded": false
  }
}
```

## Responding to webhooks

Your endpoint must return a **2xx status code** within **10 seconds**. Anything else (3xx, 4xx, 5xx, or a timeout) is treated as a failure and triggers a retry.

Sight AI does not inspect the response body — only the status code matters.

## Retries

Failed deliveries are retried up to **3 times** with exponential backoff. A failure is:

* A non-2xx HTTP response.
* A network error (DNS failure, connection refused, timeout).
* A response that takes longer than 10 seconds.

Retries use Inngest's built-in retry mechanism. Each retry re-sends the same event payload (same `id`), so your endpoint can dedup on `id` if needed.

### Auto-disable

After **10 consecutive failures**, Sight AI auto-disables the subscription (`is_active = false`) to stop hammering a broken endpoint. The subscription appears as **auto-disabled** in the Developers page. Fix your endpoint and re-create the subscription to resume delivery.

A successful delivery (2xx) resets the consecutive-failure counter to 0.

## Security

* **Verify the signature** on every incoming webhook. Never process an unverified payload.
* **Never expose the signing secret** in client-side code, logs, or error messages.
* **Use HTTPS** for your endpoint URL. Sight AI does not POST to HTTP URLs.
* **Rotate the secret** by deleting and re-creating the subscription if you suspect it's been exposed.
* Sight AI does not currently publish stable egress IPs for allowlisting. Rely on the HMAC signature for authentication rather than IP-based access control.

## Delivery history

The Developers page shows the last delivery status for each subscription. For full per-event delivery history (payload, response status, retry schedule), contact support — the `outreach_webhook_deliveries` audit table records every attempt.
