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

# Webhooks

> The end-of-interaction report Vodex POSTs to you — verifying it, and reading the outcome

When an interaction finishes, Vodex POSTs `interaction.completed` to the
endpoint set with `PUT /v1/webhook-config`.

It is `interaction.completed`, not `call.completed`, on purpose: one stream for
every channel, so you write one handler and switch on `channel` rather than
integrating a new webhook when SMS ships. The payload is deliberately
**complete** — a consumer that receives it needs no follow-up GET.

## Verifying a delivery

| Header              | Meaning                                                                          |
| ------------------- | -------------------------------------------------------------------------------- |
| `x-vodex-signature` | `t=<unix>,v1=<hex>` where `v1 = HMAC-SHA256(secret, "<t>.<raw body>")`           |
| `x-vodex-event`     | The event type                                                                   |
| `x-vodex-delivery`  | Delivery id — a retry keeps the same event `id`, so dedupe on the payload's `id` |

The timestamp is **inside** the signed string, not merely alongside it: signing
the body alone leaves a captured delivery replayable forever, because nothing in
what was signed says when it was sent. Reject anything more than **300 seconds**
off in either direction.

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

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  // A key can REPEAT: during a rotation the header carries one `v1=` per
  // active secret. Collect them into a list — `Object.fromEntries` here would
  // silently keep only the last, and reject every delivery signed by the other.
  const parts = {};
  for (const piece of header.split(",")) {
    const i = piece.indexOf("=");
    if (i < 0) continue;
    (parts[piece.slice(0, i).trim()] ??= []).push(piece.slice(i + 1).trim());
  }

  const t = Number(parts.t?.[0]);
  if (!Number.isFinite(t)) return false;
  // Both directions: a future timestamp is as suspect as an old one.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;

  const want = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "hex");
  // Any one matching is enough: you hold one secret, the header may carry two.
  return (parts.v1 ?? []).some((v) => {
    // timingSafeEqual throws on a length mismatch, so compare lengths first.
    const got = Buffer.from(v, "hex");
    return got.length === want.length && timingSafeEqual(got, want);
  });
}
```

<Warning>
  Verify against the **raw request bytes**. Re-serializing the parsed JSON is not
  guaranteed to reproduce them, and a signature is over bytes.
</Warning>

One secret signs two things — webhook deliveries, and the `tool.call` POSTs the
agent makes to your own server. Every tenant has one whether or not a webhook
endpoint is configured. During a rotation **both** secrets sign, as repeated
`v1=` values, so you can move without a coordinated deploy; the displaced value
stops after 24 hours.

## Retries

8 attempts at 10s, 30s, 2m, 10m, 30m, 2h, 6h, each with **full jitter** — a
random point in `[0, delay]`, so a consumer coming back from an outage is not
hit by every queued delivery at the same instant.

`408`, `429`, `5xx` and transport failures retry. Every other `4xx` does not:
repeating a request you called wrong just burns the budget a real outage needs.

A delivery that exhausts its attempts is the dead letter — `status: "failed"`
with no `nextAttemptAt`. It stays visible at `GET /v1/webhook-deliveries`, and
`GET /v1/webhook-deliveries/{id}` returns the exact bytes that were sent, which
is what answers "we got something, but the signature does not verify".

## Reading the outcome

`outcome` is orthogonal facts, never a prose string — you should never have to
sniff a string for what happened.

```json theme={null}
{
  "answered": true,
  "connectedMs": 41200,
  "endedBy": "agent",
  "endReason": "voicemail_left",
  "error": null,
  "voicemail": { "detected": true, "confidence": 0.67, "signals": ["acoustic", "keyword"], "action": "leaveMessage" },
  "transfer": null,
  "direction": "outbound"
}
```

**`endReason` is a fixed wire vocabulary**, deliberately separate from Vodex's
internal enum so your histograms do not shift when the internals are tidied. Two
things that might be expected there are not: whether anyone picked up is
`outcome.answered`, and a voicemail *detection* rides in `outcome.voicemail` —
the call still ended for one of the listed reasons.

**`voicemail.confidence` is not a probability.** The detector votes: three
independent signals (`acoustic`, `keyword`, `llm`), deciding on any two, or on a
confident LLM verdict alone. `confidence` is the share that agreed — `0.67` is
the ordinary two-vote decision, `1.0` is all three, `0.33` is the LLM deciding
alone. The words behind it are in `signals`.

**`transfer` is the transfer that actually happened.** A transfer is a sequence —
requested, then resolved — and this reports the last one, so a warm attempt that
failed and fell back to a cold one that completed reports the cold one.

`analysis` is an open envelope: summary and sentiment land there as additive
keys, so a handler written today keeps parsing tomorrow's payloads.

The full schema, field by field, is on
[Set the webhook endpoint](/api-reference/webhooks/set-the-webhook-endpoint) —
`InteractionCompleted` is the callback body it documents.
