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

# Webhooks

> Outbound webhook endpoints with signed delivery.

**Webhook endpoints** let Naive Managed Agents push [events](https://vetta.sh/docs/api/events.md) to your server as they happen, instead of you polling. Each delivery is signed so you can verify it came from Managed Agents and was not tampered with.

> [!NOTE]
>
> This page covers **outbound** delivery (Managed Agents → your server). Inbound email and SMS land on an [identity](https://vetta.sh/docs/api/identities.md)'s message feed and wake the inbox's or number's owning agent — see [Inbound events](https://vetta.sh/docs/identity/inbound.md).

## The webhook endpoint object

**id** `string`

Unique id (e.g. `whk_01H...`).

**url** `string`

Your HTTPS URL that receives deliveries.

**events** `string[]`

Event types to deliver (e.g. `session.idle`, `budget.exceeded`).

**secret** `string`

Signing secret. Returned only at creation and on [rotate](#rotate-the-signing-secret).

**enabled** `boolean`

Whether the endpoint receives deliveries. Set via [update](#update-an-endpoint); auto-disabled after sustained failures.

**created\_at** `string`

Creation timestamp.

## Create an endpoint

`POST /v1/webhooks` → `201 Created`. The signing secret is always minted server-side and returned once, on this response.

**url** `string` *required*

HTTPS URL to receive events.

**events** `string[]` *required*

Event types to subscribe to. Use `["*"]` for all.

```bash cURL theme={"system"}
curl -fsSL https://api.vetta.sh/v1/webhooks \
  -H "authorization: Bearer sk_live_..." \
  -H "content-type: application/json" \
  -H "idempotency-key: $(uuidgen)" \
  -d '{ "url": "https://example.com/hooks/vetta", "events": ["session.idle", "budget.exceeded"] }'
```

```json Response theme={"system"}
{
  "id": "whk_01H9HH...",
  "object": "webhook_endpoint",
  "url": "https://example.com/hooks/vetta",
  "events": ["session.idle", "budget.exceeded"],
  "secret": "whsec_3f9a...redacted",
  "enabled": true,
  "created_at": "2026-08-20T17:00:00Z"
}
```

## Retrieve, list & delete

```bash theme={"system"}
GET    /v1/webhooks        # list (cursor-paginated) -> 200
GET    /v1/webhooks/{id}   # retrieve one -> 200
DELETE /v1/webhooks/{id}   # remove an endpoint -> 200
```

```json Delete response theme={"system"}
{ "id": "whk_01H9HH...", "object": "webhook_endpoint", "deleted": true }
```

See [Pagination](https://vetta.sh/docs/api/pagination.md).

## Update an endpoint

`PATCH /v1/webhooks/{id}` → `200 OK`. Change the `url`, subscribed `events`, or pause/resume delivery with `enabled`.

**url** `string`

New HTTPS URL.

**events** `string[]`

Replace the subscribed event types.

**enabled** `boolean`

Set `false` to pause delivery, `true` to resume.

```bash theme={"system"}
curl -fsSL -X PATCH https://api.vetta.sh/v1/webhooks/whk_01H9HH... \
  -H "authorization: Bearer sk_live_..." \
  -H "content-type: application/json" \
  -d '{ "enabled": false }'
```

## Send a test delivery

`POST /v1/webhooks/{id}/test` → `202 Accepted`. Queues one signed delivery to the endpoint so you can prove your verifier works before a real event depends on it. It ignores the endpoint's `events` filter — you asked for this one — and carries the type `webhook.test`, so a handler that switches on `type` can ignore it safely. A disabled endpoint is refused rather than queued.

```bash theme={"system"}
curl -fsSL -X POST https://api.vetta.sh/v1/webhooks/whk_01H9HH.../test \
  -H "authorization: Bearer sk_live_..."
```

```json Response theme={"system"}
{
  "id": "whd_01H9JJ...",
  "event_id": "evt_01H9JJ...",
  "event_type": "webhook.test",
  "status": "pending",
  "attempts": 0,
  "response_status": null,
  "response_snippet": null,
  "delivered_at": null,
  "next_retry_at": null
}
```

The response is the delivery record, not the receiver's answer — the POST happens asynchronously. Poll [the delivery log](#delivery-log--redelivery) for the outcome.

## Rotate the signing secret

`POST /v1/webhooks/{id}/rotate` → `200 OK`. Issues a **new** `secret` (shown once). During the overlap window, deliveries are signed with **both** the old and new secrets, so you can roll your verifier with zero missed events. When the window closes, only the new secret signs.

**overlap\_hours** `integer`

How long both secrets stay valid. Defaults to `24`; `24` is the max. Use `0` to cut over immediately.

```bash theme={"system"}
curl -fsSL https://api.vetta.sh/v1/webhooks/whk_01H9HH.../rotate \
  -H "authorization: Bearer sk_live_..." \
  -H "content-type: application/json" \
  -d '{ "overlap_hours": 24 }'
```

```json Response theme={"system"}
{
  "id": "whk_01H9HH...",
  "secret": "whsec_a71c...redacted",
  "overlap_expires_at": "2026-08-21T17:00:00Z"
}
```

## Delivery payload

Each delivery is a `POST` to your URL with a JSON body wrapping the event, plus signature headers.

```json Body theme={"system"}
{
  "id": "evt_01H9II...",
  "type": "session.idle",
  "created_at": "2026-08-20T17:05:04Z",
  "data": { "session_id": "ses_01H9AB...", "stop_reason": "end_turn" }
}
```

| Header | Meaning |
| - | - |
| `Vetta-Signature` | One or more scheme-versioned HMAC-SHA256 signatures, e.g. `v1=<hex>`. During a [secret rotation](#rotate-the-signing-secret) both signatures are present, comma-separated: `v1=<old>,v1=<new>`. |
| `Vetta-Timestamp` | Unix seconds when the delivery was signed. |

## Signature verification

Each signature is an **HMAC-SHA256 over `{timestamp}.{raw_body}`** keyed by your endpoint `secret`, encoded as `v1=<hex>`. The scheme version (`v1`) lets us evolve the algorithm without breaking verifiers, and multiple signatures let a secret rotation overlap.

To verify:

1. Read the raw request body (do not re-serialize it).
2. Build the signed payload: `` `${timestamp}.${rawBody}` ``.
3. Compute your own `v1` HMAC-SHA256 and check it against **any** signature in the header (both secrets are valid during an overlap window).
4. Reject the delivery if the timestamp is too old (e.g. > 5 minutes).

```typescript theme={"system"}
import crypto from "node:crypto";

function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  // Length check first — timingSafeEqual throws on mismatched lengths.
  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}

function verify(rawBody: string, header: string, timestamp: string, secret: string): boolean {
  const signed = `${timestamp}.${rawBody}`;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
  // Header may carry several signatures during a rotation overlap.
  const candidates = header
    .split(",")
    .map((s) => s.trim())
    .filter((s) => s.startsWith("v1="))
    .map((s) => s.slice(3));
  const ok = candidates.some((sig) => safeEqual(sig, expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
  return ok && fresh;
}
```

> [!WARNING]
>
> Verify against the **raw** bytes of the body. Parsing and re-stringifying JSON changes whitespace and key order, which breaks the HMAC. Always length-check before `timingSafeEqual` — it throws a `RangeError` on mismatched buffer lengths, which an attacker could trigger with a malformed header.

## Delivery log & redelivery

Every attempt is recorded. Inspect it, drill into one delivery, and manually resend.

```bash theme={"system"}
GET  /v1/webhooks/{id}/deliveries                    # list (cursor-paginated) -> 200
GET  /v1/webhooks/{id}/deliveries/{delivery_id}      # retrieve one -> 200
POST /v1/webhooks/{id}/deliveries/{delivery_id}/redeliver   # resend -> 202
```

A redelivery re-sends the **same envelope**, so the `id` your handler dedupes on is unchanged. It is recorded as a **new** delivery record with its own attempt count, which leaves the original attempt's outcome intact in the log.

**id** `string`

Delivery id.

**event\_id** `string`

The `evt_...` id delivered.

**event\_type** `string`

The event type.

**status** `string`

`pending`, `succeeded`, or `failed`.

**attempts** `integer`

Number of attempts so far.

**response\_status** `integer | null`

HTTP status your server returned on the last attempt.

**response\_snippet** `string | null`

First bytes of your server's response body, for debugging.

**delivered\_at** `string | null`

When the delivery first succeeded.

**next\_retry\_at** `string | null`

When the next retry is scheduled, if still pending.

```json Response theme={"system"}
{
  "data": [
    {
      "id": "whd_01H9JK...",
      "event_id": "evt_01H9II...",
      "event_type": "session.idle",
      "status": "failed",
      "attempts": 3,
      "response_status": 500,
      "response_snippet": "internal error",
      "delivered_at": null,
      "next_retry_at": "2026-08-20T17:20:00Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

## Delivery, retries & auto-disable

Respond `2xx` within **10 seconds** to acknowledge; slower responses count as a timeout. Any non-`2xx` response or timeout is retried with exponential backoff over \~24 hours. Deliveries carry a stable `id` — dedupe on it, since a delivery may occasionally arrive more than once.

If an endpoint returns only failures for **24 hours**, Managed Agents flips it to `enabled: false` and stops delivering. Fix your endpoint, re-enable it with [update](#update-an-endpoint), and [redeliver](#delivery-log--redelivery) any missed events.

## Inbound receivers

Four routes under `/v1/webhooks/` point the other way: they are **inbound receivers** the platform exposes for a provider to call, not endpoints you register or invoke.

| Route | Called by | What it does |
| - | - | - |
| `POST /v1/webhooks/stripe` | The payment provider | Settles a [top-up](https://vetta.sh/docs/api/credits.md#top-up) and credits the organization's balance. |
| `POST /v1/webhooks/inbound/email` | The email provider | Turns arriving mail into an [inbound message](https://vetta.sh/docs/api/messaging.md#list-inbound-messages) and wakes the owning agent. |
| `POST /v1/webhooks/inbound/sms` | The SMS carrier | The same, for a text message. |
| `POST /v1/webhooks/legal` | The identity-verification provider | Moves a [legal verification](https://vetta.sh/docs/api/legal.md) member to `verified`, `failed` or `pending_review`; terminal states are never flipped. |

> [!NOTE]
>
> These take a **provider** signature, not your API key, and there is no CLI or SDK verb for them by design — a caller with an API key has a first-class route for everything these do. They are listed here so the route table has no unexplained entries, not because you should call them.

Each verifies its provider signature and answers `2xx` on acceptance; an unverifiable payload is rejected without side effects and never reaches an agent.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.