> ## 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 Vetta push [events](/docs/api/events) to your server as they happen, instead of you polling. Each delivery is signed so you can verify it came from Vetta and was not tampered with.

<Info>
  This page covers **outbound** delivery (Vetta → your server). Inbound email/SMS receiving lands on an [identity](/docs/api/identities)'s message feed today; the agent wake from a stored inbound message is coming soon — see [Inbound events](/docs/identity/inbound).
</Info>

## The webhook endpoint object

<ResponseField name="id" type="string">Unique id (e.g. `whk_01H...`).</ResponseField>
<ResponseField name="url" type="string">Your HTTPS URL that receives deliveries.</ResponseField>
<ResponseField name="events" type="string[]">Event types to deliver (e.g. `session.idle`, `budget.exceeded`).</ResponseField>
<ResponseField name="secret" type="string">Signing secret. Returned only at creation and on [rotate](#rotate-the-signing-secret).</ResponseField>
<ResponseField name="enabled" type="boolean">Whether the endpoint receives deliveries. Set via [update](#update-an-endpoint); auto-disabled after sustained failures.</ResponseField>
<ResponseField name="created_at" type="string">Creation timestamp.</ResponseField>

## Create an endpoint

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

<ParamField body="url" type="string" required>HTTPS URL to receive events.</ParamField>
<ParamField body="events" type="string[]" required>Event types to subscribe to. Use `["*"]` for all.</ParamField>

<CodeGroup>
  ```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"] }'
  ```
</CodeGroup>

<ResponseExample>
  ```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"
  }
  ```
</ResponseExample>

## 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](/docs/api/pagination).

## Update an endpoint

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

<ParamField body="url" type="string">New HTTPS URL.</ParamField>
<ParamField body="events" type="string[]">Replace the subscribed event types.</ParamField>
<ParamField body="enabled" type="boolean">Set `false` to pause delivery, `true` to resume.</ParamField>

```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.

<ParamField body="overlap_hours" type="integer">How long both secrets stay valid. Defaults to `24`; `24` is the max. Use `0` to cut over immediately.</ParamField>

```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.
</Warning>

## 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.

<ResponseField name="id" type="string">Delivery id.</ResponseField>
<ResponseField name="event_id" type="string">The `evt_...` id delivered.</ResponseField>
<ResponseField name="event_type" type="string">The event type.</ResponseField>
<ResponseField name="status" type="string">`pending`, `succeeded`, or `failed`.</ResponseField>
<ResponseField name="attempts" type="integer">Number of attempts so far.</ResponseField>
<ResponseField name="response_status" type="integer | null">HTTP status your server returned on the last attempt.</ResponseField>
<ResponseField name="response_snippet" type="string | null">First bytes of your server's response body, for debugging.</ResponseField>
<ResponseField name="delivered_at" type="string | null">When the delivery first succeeded.</ResponseField>
<ResponseField name="next_retry_at" type="string | null">When the next retry is scheduled, if still pending.</ResponseField>

```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**, Vetta 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

Three 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](/docs/api/credits#top-up) and credits the organization's balance.                                 |
| `POST /v1/webhooks/inbound/email` | The email provider   | Turns arriving mail into an [inbound message](/docs/api/messaging#list-inbound-messages) and wakes the owning agent. |
| `POST /v1/webhooks/inbound/sms`   | The SMS carrier      | The same, for a text message.                                                                                   |

<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.
</Note>

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