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

# Webhooks

> Six signed events, delivered with retries. Every payload carries your userRef.

## Delivery

Every event is a `POST` to your webhook URL with a JSON body and these headers:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-Verso-Event` | The event name, e.g. `connection.created` |
| `X-Verso-Signature` | `t=<unix seconds>,v1=<hmac>`; present when a webhook secret is configured for your app |

```json theme={null}
{
  "event": "connection.created",
  "payload": { "...": "..." },
  "deliveryId": "770e8400-e29b-41d4-a716-446655440000"
}
```

`deliveryId` identifies the delivery, not the event: a retried delivery keeps the same id. Use it to ignore duplicates.

## Events

| Event | Sent when |
| - | - |
| `connect.started` | The user opened a connect link and the login page started |
| `connection.created` | The user logged in; a connection exists and its first sync is starting |
| `data.imported` | A sync or an export ingestion inserted or updated at least one conversation |
| `reauth.needed` | The provider rejected the stored session; the user must connect again |
| `data.deleted` | All data of a user in your app was deleted (manage page or user purge) |
| `connection.deleted` | One connection was deleted through the purge API |

Every payload includes `userRef`, the identifier you passed to `signLink()`, so you can route the event without a lookup.

### `connect.started`

```json theme={null}
{
  "event": "connect.started",
  "payload": {
    "userRef": "user_123",
    "user_id": "660e8400-e29b-41d4-a716-446655440000",
    "connector": "chatgpt",
    "scopes": ["conversations:read"]
  },
  "deliveryId": "…"
}
```

`user_id` is Verso's internal id for the user. Nothing is connected yet; the user may still abandon the page.

### `connection.created`

```json theme={null}
{
  "event": "connection.created",
  "payload": {
    "connectionId": "550e8400-e29b-41d4-a716-446655440000",
    "userRef": "user_123",
    "userId": "660e8400-e29b-41d4-a716-446655440000",
    "connectorId": "chatgpt-live",
    "provider": "chatgpt",
    "replaces": ["440e8400-e29b-41d4-a716-446655440000"]
  },
  "deliveryId": "…"
}
```

`replaces` lists previous connections of the same user and provider account, now `revoked`. Their conversations moved to `connectionId`. It is empty on a first connection.

### `data.imported`

```json theme={null}
{
  "event": "data.imported",
  "payload": {
    "connectionId": "550e8400-e29b-41d4-a716-446655440000",
    "userRef": "user_123",
    "inserted": 12,
    "updated": 3,
    "unchanged": 127,
    "total": 142,
    "importedAt": "2026-09-29T03:00:12.000Z"
  },
  "deliveryId": "…"
}
```

Not sent when a sync finds nothing new. `inserted` counts conversations seen for the first time, which during the initial backfill includes old history; use `GET /api/connections` for recent activity.

### `reauth.needed`

```json theme={null}
{
  "event": "reauth.needed",
  "payload": {
    "connectionId": "550e8400-e29b-41d4-a716-446655440000",
    "userRef": "user_123",
    "provider": "chatgpt",
    "reason": "401: conversation listing rejected",
    "lastSuccessfulPull": "2026-09-27T03:00:12.000Z"
  },
  "deliveryId": "…"
}
```

Sign a new connect link for the user. Until they reconnect, the connection stays `needs_reauth` and its conversations remain readable.

### `data.deleted`

```json theme={null}
{
  "event": "data.deleted",
  "payload": {
    "userRef": "user_123",
    "providers": ["chatgpt"],
    "connectionIds": ["550e8400-e29b-41d4-a716-446655440000"],
    "conversationsDeleted": 142,
    "deletedAt": "2026-09-29T16:00:00.000Z"
  },
  "deliveryId": "…"
}
```

### `connection.deleted`

```json theme={null}
{
  "event": "connection.deleted",
  "payload": {
    "connectionId": "550e8400-e29b-41d4-a716-446655440000",
    "userRef": "user_123",
    "provider": "chatgpt",
    "conversationsDeleted": 142,
    "deletedAt": "2026-09-29T16:00:00.000Z"
  },
  "deliveryId": "…"
}
```

## Verifying signatures

The signature is Stripe-style: `t` is the Unix timestamp of the delivery, `v1` is `HMAC-SHA256(secret, t + "." + body)` in hex. Verify on the raw body.

```typescript theme={null}
import { verifyWebhook } from "@versoai/core";

app.post("/webhooks/verso", express.raw({ type: "application/json" }), (req, res) => {
  const body = req.body.toString("utf8");
  if (!verifyWebhook(process.env.VERSO_WEBHOOK_SECRET!, req.get("x-verso-signature") ?? "", body)) {
    return res.status(401).end();
  }
  // …
});
```

`verifyWebhook` rejects signatures older than 5 minutes by default; pass a fourth argument to change the tolerance. Without the SDK:

```typescript theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret: string, header: string, body: string, toleranceSec = 300): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
  return expected.length === parts.v1.length &&
    timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"));
}
```

## Retries

Your endpoint must answer with a 2xx status within 10 seconds. Anything else, including a timeout, counts as a failure and the delivery is retried with exponential backoff, six attempts in total.

| Attempt | Delay after the previous one |
| - | - |
| 1 | immediate |
| 2 | 2 minutes |
| 3 | 4 minutes |
| 4 | 8 minutes |
| 5 | 16 minutes |
| 6 | 32 minutes |

After the sixth failure the delivery is dropped. Deliveries are independent: a retried event can arrive after a newer one, so do not rely on ordering. If your app has no webhook URL configured, events are discarded.
