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

# Webhooks

> Receive a call's lifecycle events at your own URL, call any URL on a rule-driven schedule, and read the log of every request a call's processing went through.

CallVA talks HTTP to your systems in three ways, and each has its own page section:

> [card] [Call event webhooks](#call-event-webhooks)
>
> An agent forwards `call.dialing`, `call.started`, `call.ended` and `call.recording` to a URL you name.

> [card] [Webhook schedules](#webhook-schedules)
>
> Call a URL, or run an automation, at times decided by rules: weekdays, windows, intervals, dates.

> [card] [Webhook logs](#webhook-logs)
>
> The record of every request around a call: dispatch, room, config fetch and each report the voice engine sent.

Everything below works with an API token whose role holds the matching permission: `agents.update` to configure an agent's webhook, `schedules.read` / `schedules.create` / `schedules.update` / `schedules.delete` for schedules, `webhook_logs.read` for logs. See [Authentication](https://docs.callva.io/authentication.md) for tokens and roles.

```bash theme={null}
export CALLVA_API="https://api.callva.io/api/v1/external"
export CALLVA_TOKEN="<your API token>"
```

## Call event webhooks

A voice agent can forward the events of every call it handles to a URL of yours. You choose which of the four event types leave the platform; the rest are processed by CallVA as always and go nowhere else.

| Event | When it fires |
| - | - |
| `call.dialing` | An outbound call starts ringing the other party. Sent once, however many rings follow. Inbound calls never send it. |
| `call.started` | The conversation begins: the outbound call is answered, or the inbound caller is joined. |
| `call.ended` | The call is over - answered, unanswered or failed - with its transcript and usage. |
| `call.recording` | The call's recording is stored and ready to play. Arrives after `call.ended`. |

Call event webhooks are produced for calls that run on CallVA's own voice engine (the LiveKit worker), inbound and outbound.

### How a call's events travel

The voice worker reports each event to CallVA, signed with the project's secret. CallVA verifies it, updates the call record (status, parties, timestamps, transcript, recording, billing), and only then forwards the report to your URL if the agent subscribes to that event type.

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant W as CallVA voice worker
    participant C as CallVA platform
    participant Y as Your webhook URL

    Note over W: Outbound call is dialled
    W->>C: call.dialing (signed)
    C->>C: Verify, set status "calling"
    C-->>Y: POST call.dialing (if subscribed)
    Note over W: Person answers
    W->>C: call.started (signed)
    C->>C: Verify, set status "in_progress"
    C-->>Y: POST call.started (if subscribed)
    Note over W: Call ends
    W->>C: call.ended (signed)
    C->>C: Verify, store outcome, transcript, usage
    C-->>Y: POST call.ended (if subscribed)
    Note over W: Audio upload finishes
    W->>C: call.recording (signed)
    C->>C: Verify, store recording key
    C-->>Y: POST call.recording (if subscribed)
```

### Configure the receiving URL

The URL lives on the agent. Three fields control it, set with [Update an agent](https://docs.callva.io/api-reference/agents/update-an-agent.md) (`PATCH /agents/{id}`) or in the dashboard on the agent's settings:

**custom\_webhook\_enabled** `boolean`

Turns forwarding on or off.

**custom\_webhook\_url** `string`

Where the events are posted. Any valid URL up to 2048 characters.

**custom\_webhook\_events** `string[]`

The event types to forward: any of `call.dialing`, `call.started`, `call.ended`, `call.recording`. The list you send replaces the stored one. An empty list forwards nothing, even with forwarding enabled and a URL set.

```bash theme={null}
curl -X PATCH "$CALLVA_API/agents/01a1c749-5593-707d-877f-f89da2be547b" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "custom_webhook_enabled": true,
    "custom_webhook_url": "https://hooks.example.com/callva",
    "custom_webhook_events": ["call.ended", "call.started"]
  }'
```

```json Response (trimmed) theme={null}
{
  "success": true,
  "message": "Agent updated successfully",
  "data": {
    "id": "01a1c749-5593-707d-877f-f89da2be547b",
    "name": "[TEST] docs-4d48b3d0 pipeline",
    "custom_webhook_enabled": true,
    "custom_webhook_url": "https://hooks.example.com/callva",
    "custom_webhook_events": ["call.started", "call.ended"],
    ...
  }
}
```

The stored list comes back de-duplicated and in the order a call meets the events. An unknown event name is refused with `422`:

```json theme={null}
{
  "success": false,
  "message": "Validation failed",
  "errors": { "custom_webhook_events.0": ["The selected custom_webhook_events.0 is invalid."] }
}
```

> [!TIP]
>
> **Where to send, per call.** An [outbound call](https://docs.callva.io/api-reference/outbound-calls/place-an-outbound-call.md) may name a different URL for itself in `agent_overrides.custom_webhook_url`; that call's events go there. **Which events** leave is always read from the agent as it stands when each event arrives, so unsubscribing an event type stops it at once, even for calls already in progress.

### Payloads

Each event is posted as a JSON object - the envelope the voice worker reported, with CallVA's own credentials (signing secret, TURN password, LiveKit keys) removed and nothing else changed.

**event** `string` *required*

`call.dialing`, `call.started`, `call.ended` or `call.recording`.

**id** `string` *required*

Unique id of this report: `<call id>:<event>:<milliseconds>`.

**timestamp** `number` *required*

When the report was built, Unix seconds with a fraction.

**call** `object` *required*

The call. `id` is the CallVA call id - use it with [Get a call](https://docs.callva.io/api-reference/calls/get-a-call.md).

**properties**

**id** `string`

CallVA call id.

**direction** `string`

`outbound` or `inbound`.

**from** `object`

The party that placed the call: `number`, `identity`, `name`. On an outbound call this is your line.

**to** `object`

The party that received the call, same shape. On an outbound call this is the person called.

**started\_at** `number | null`

Unix seconds when the conversation started.

**ended\_at** `number | null`

Unix seconds when it ended.

**duration** `number | null`

Seconds between start and end.

**status** `string`

`dialing` on `call.dialing`, `in_progress` on `call.started`. On `call.ended` the outcome: `completed`, `no_answer`, `rejected`, `canceled` or `failed`.

**ended\_by** `string | null`

Who ended the call: `agent`, `user`, `silence`, `duration` or `no_answer`.

**reason** `string | null`

Why a call nobody joined never began.

**sip\_status\_code** `number | null`

The carrier's SIP response code, when it refused the dial.

**sip\_status** `string | null`

The carrier's SIP response phrase.

**project\_id** `string`

CallVA project id.

**tenant\_id** `string`

CallVA organization id.

**agent** `object`

The agent configuration the call ran with, including any per-call override: `id`, `name`, `prompt`, greeting and timeout settings, `custom_webhook_enabled`, `custom_webhook_url`.

**environment** `string | null`

Which CallVA deployment served the call.

**livekit** `object`

What the voice engine reported, verbatim: `room`, `job`, `participant`, `sip` (SIP attributes such as `callStatus`) and `disconnect_reason`. On `call.ended` it also carries `session_report`, with `chat_history.items` (the raw conversation) and `usage` (per-provider usage). Not present on `call.recording`.

**recording** `object | null`

On `call.ended`, where the recording will be stored: `delivery`, `audio_key`, `session_report_key`. On `call.recording`, the same with `stored: true`. Keys are object names, never URLs; play the audio through [Stream a call's recording](https://docs.callva.io/api-reference/recordings/stream-a-calls-recording.md).

**errors** `array | null`

What went wrong during the call, when anything did.

**tags** `object | null`

Session tags and outcome the agent recorded: `tags`, `outcome`, `reason`.

> [tab] call.dialing
>
> ```json theme={null}
> {
>   "event": "call.dialing",
>   "id": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0:call.dialing:1789000000000",
>   "timestamp": 1789000000.0,
>   "call": {
>     "id": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0",
>     "direction": "outbound",
>     "from": { "number": "+15555550199", "identity": null, "name": null },
>     "to": { "number": "+15555550100", "identity": "sip_+15555550100", "name": null },
>     "started_at": null,
>     "ended_at": null,
>     "duration": null,
>     "status": "dialing",
>     "ended_by": null,
>     "reason": null,
>     "sip_status_code": null,
>     "sip_status": null,
>     "project_id": "01a1c749-5525-731a-ab9f-61ffbf9e4ef2",
>     "tenant_id": "01a1c749-5387-7002-8154-5a7913629e6e",
>     "type": "sip"
>   },
>   "agent": {
>     "id": "01a1c749-5593-707d-877f-f89da2be547b",
>     "name": "Reminder agent",
>     "custom_webhook_enabled": true,
>     "custom_webhook_url": "https://hooks.example.com/callva",
>     ...
>   },
>   "environment": "production",
>   "livekit": {
>     "room": { "name": "...", "sid": "...", "metadata": null },
>     "job": { ... },
>     "participant": { "identity": "sip_+15555550100", ... },
>     "sip": { "callStatus": "dialing", ... }
>   },
>   "recording": null,
>   "errors": null,
>   "tags": null
> }
> ```

> [tab] call.started
>
> ```json theme={null}
> {
>   "event": "call.started",
>   "id": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0:call.started:1789000004500",
>   "timestamp": 1789000004.5,
>   "call": {
>     "id": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0",
>     "direction": "outbound",
>     "from": { "number": "+15555550199", "identity": null, "name": null },
>     "to": { "number": "+15555550100", "identity": "sip_+15555550100", "name": null },
>     "started_at": 1789000004.5,
>     "ended_at": null,
>     "duration": null,
>     "status": "in_progress",
>     "ended_by": null,
>     ...
>   },
>   "agent": { "id": "01a1c749-5593-707d-877f-f89da2be547b", ... },
>   "environment": "production",
>   "livekit": { "room": { ... }, "job": { ... }, "participant": { ... }, "sip": { ... } },
>   "recording": null,
>   "errors": null,
>   "tags": null
> }
> ```

> [tab] call.ended
>
> ```json theme={null}
> {
>   "event": "call.ended",
>   "id": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0:call.ended:1789000046500",
>   "timestamp": 1789000046.5,
>   "call": {
>     "id": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0",
>     "direction": "outbound",
>     "from": { "number": "+15555550199", "identity": null, "name": null },
>     "to": { "number": "+15555550100", "identity": "sip_+15555550100", "name": null },
>     "started_at": 1789000004.5,
>     "ended_at": 1789000046.5,
>     "duration": 42.0,
>     "status": "completed",
>     "ended_by": "user",
>     "reason": null,
>     "sip_status_code": null,
>     "sip_status": null,
>     ...
>   },
>   "agent": { "id": "01a1c749-5593-707d-877f-f89da2be547b", ... },
>   "environment": "production",
>   "livekit": {
>     "room": { ... },
>     "job": { ... },
>     "participant": { ... },
>     "sip": { ... },
>     "disconnect_reason": "CLIENT_INITIATED",
>     "session_report": {
>       "chat_history": {
>         "items": [
>           { "type": "message", "role": "assistant", "content": ["Hello, this is a reminder call."], "created_at": 1789000005.1 },
>           { "type": "message", "role": "user", "content": ["Hi, thanks."], "created_at": 1789000009.8 }
>         ]
>       },
>       "usage": [ ... ]
>     }
>   },
>   "recording": {
>     "delivery": "storage",
>     "audio_key": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0.ogg",
>     "session_report_key": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0.session.json"
>   },
>   "errors": null,
>   "tags": null
> }
> ```

> [tab] call.recording
>
> ```json theme={null}
> {
>   "event": "call.recording",
>   "id": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0:call.recording:1789000048000",
>   "timestamp": 1789000048.0,
>   "call": { "id": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0", ... },
>   "agent": { "id": "01a1c749-5593-707d-877f-f89da2be547b", ... },
>   "environment": "production",
>   "recording": {
>     "delivery": "storage",
>     "audio_key": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0.ogg",
>     "session_report_key": "01a1c74e-54f2-73f6-8af6-3ef541efe3a0.session.json",
>     "stored": true
>   },
>   "tags": null
> }
> ```
>
> `call.recording` is thin on purpose: no `livekit` block, no transcript, no usage. All of that arrived with `call.ended` under the same call id.

The transcript CallVA stores is built from `livekit.session_report.chat_history`, with consecutive pieces of one speaker merged into utterances. If you want the stored version rather than the raw one, read it with [Get a call's transcript](https://docs.callva.io/api-reference/transcripts/get-a-calls-transcript.md).

### Delivery: headers, timeout, retries

| | |
| - | - |
| Method | `POST`, body is the JSON envelope |
| Headers | `Content-Type: application/json`. No signature header. |
| Timeout | 5 seconds |
| Retries | None. One attempt per event. |
| Your answer | Read by nobody: any status counts as delivered. Answer `2xx` quickly and do slow work after responding. |
| Ordering | Sent as CallVA processes each report, so normally `dialing`, `started`, `ended`, `recording`. |
| Logged | Not in [webhook logs](#webhook-logs); the report that triggered it is. |

> [!WARNING]
>
> The forwarded request is not signed, so treat its body as a notification, not as proof. Use an HTTPS URL that is hard to guess (for example with a long random path segment), and before you act on an event, confirm it by reading the call with [Get a call](https://docs.callva.io/api-reference/calls/get-a-call.md). Deduplicate on the envelope `id`: if CallVA fails while processing a report, the worker retries that report and your URL can see the same `id` twice.

### The project's webhook signing secret

Every project has a signing secret of its own (the `agent_webhook_secret` project setting). It authenticates the reports a voice worker sends **to** CallVA - the signed arrows in the diagram above - and only that project's secret verifies that project's calls. CallVA's own worker receives the secret with each call's configuration, so you need it only if you run your own LiveKit worker against CallVA, or relay reports to CallVA yourself.

The worker signs each report like this:

```http theme={null}
POST /api/v1/webhooks/livekit
Content-Type: application/json
X-Webhook-Timestamp: 1789000046
X-Webhook-Signature: sha256=<hex HMAC-SHA256 of "{timestamp}.{raw body}" keyed with the secret>
X-Webhook-Idempotency-Key: <unique per report; a repeat returns the first answer>
```

CallVA recomputes the HMAC over the exact raw body, compares in constant time, and refuses with `401` when a header is missing (`missing_signature`), the timestamp is more than 300 seconds from its clock (`timestamp_expired`), the signature does not start with `sha256=` (`invalid_signature_format`), the call is unknown (`unknown_call`), or the HMAC differs (`invalid_signature`). The same check, in code:

```javascript Node.js theme={null}
import crypto from "node:crypto";

const MAX_DRIFT_SECONDS = 300;

// Sign a raw JSON body the way CallVA expects.
export function sign(secret, rawBody, timestamp = Math.floor(Date.now() / 1000)) {
  const hex = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  return { "X-Webhook-Timestamp": String(timestamp), "X-Webhook-Signature": `sha256=${hex}` };
}

// Verify a request: rawBody must be the exact bytes received, not re-serialized JSON.
export function verify(secret, rawBody, timestampHeader, signatureHeader) {
  if (!timestampHeader || !signatureHeader) return false;
  const ts = Number(timestampHeader);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_DRIFT_SECONDS) return false;
  if (!signatureHeader.startsWith("sha256=")) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestampHeader}.${rawBody}`)
    .digest("hex");
  const given = signatureHeader.slice(7);
  return given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
```

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

MAX_DRIFT_SECONDS = 300


def sign(secret: str, raw_body: bytes, timestamp: int | None = None) -> dict[str, str]:
    """Headers for a raw JSON body, signed the way CallVA expects."""
    ts = str(timestamp if timestamp is not None else int(time.time()))
    digest = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return {"X-Webhook-Timestamp": ts, "X-Webhook-Signature": f"sha256={digest}"}


def verify(secret: str, raw_body: bytes, timestamp_header: str | None, signature_header: str | None) -> bool:
    """raw_body must be the exact bytes received, not re-serialized JSON."""
    if not timestamp_header or not signature_header:
        return False
    try:
        ts = int(timestamp_header)
    except ValueError:
        return False
    if abs(time.time() - ts) > MAX_DRIFT_SECONDS:
        return False
    if not signature_header.startswith("sha256="):
        return False
    expected = hmac.new(secret.encode(), timestamp_header.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header[7:], expected)
```

To replace the secret, call [Rotate the webhook signing secret](https://docs.callva.io/api-reference/project-settings/rotate-the-webhook-signing-secret.md). The old secret stops verifying at once, so reports from calls already running with it are refused. The answer confirms `has_value` and never contains the secret:

```bash theme={null}
curl -X POST "$CALLVA_API/settings/project/agent-webhook-secret/generate" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json"
```

```json Response (trimmed) theme={null}
{
  "success": true,
  "message": "Webhook signing secret generated",
  "data": {
    "key": "agent_webhook_secret",
    "label": "Agent Webhook Secret",
    "scope": "project",
    "is_secret": true,
    "has_value": true,
    "value": null,
    ...
  }
}
```

Read the value with [Reveal a project setting](https://docs.callva.io/api-reference/project-settings/reveal-a-project-setting.md), which needs the `project_secrets.read` permission. Generating needs `settings.update`.

## Webhook schedules

A webhook schedule calls one URL - or runs one [automation](https://docs.callva.io/guides/automations.md) - at the moments its rules allow. Use it for a nightly sync, a report every weekday morning, a heartbeat every five minutes inside office hours.

### The model

A **schedule** is the target and how to call it: `webhook_url`, `webhook_method` (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`), `webhook_headers`, `webhook_payload`, `timeout_seconds` (1-300, default 30), `max_retries` (0-10, default 3), `retry_delay_seconds` (0-3600, default 60) and `is_active`. With `target_type: "automation"` and an `automation_id`, it runs that automation instead of calling a URL.

A **rule** says when. Each rule has a `priority` (lower runs first), an `action` (`run` or `skip`), and conditions that must all pass:

| Condition | Fields | Passes when |
| - | - | - |
| Date | `date_from`, `date_to` | Unset: always. Only `date_from`: that day. Both: any day in the range, inclusive. |
| Weekday | `days_of_week` | Empty: always. Otherwise the day is listed, `0` = Sunday ... `6` = Saturday. |
| Time window | `time_from`, `time_to` (`HH:mm` or `HH:mm:ss`) | Unset: always. A window whose end is before its start runs overnight. |

`execution_interval` (seconds, default 60, at least 10 for `run` rules, at most 86400) sets the cadence inside a matching window. Times are anchored to `time_from` (or midnight): an interval of 300 from `09:00` fires at 09:00, 09:05, 09:10 and so on, with `time_to` inclusive.

Rules are evaluated in priority order and the **first rule whose conditions all pass wins**. When no rule matches, nothing runs. A rule with `is_exclusive: true` and a date "owns" its dates: on those days no lower-priority rule is consulted, and outside its own time window the answer is `skip`. Use it for holidays and one-off days.

Rules are evaluated in the schedule's timezone: the project's `timezone` setting, else the organization's, else UTC.

### Pre-schedule, then execute

A scheduler ticks every ten seconds and does three things in order.

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant T as Scheduler tick (every 10 s)
    participant DB as Executions
    participant Y as Your URL / automation

    T->>DB: Pre-schedule: for each active schedule, walk the next 10 min<br/>in 10 s steps, write one "scheduled" execution per run time
    Note over DB: Upcoming runs are visible in the execution history
    T->>DB: Execute: claim due "scheduled" executions<br/>(scheduled -> processing, one winner)
    alt schedule or rule deactivated since
        T->>DB: mark "skipped", no request sent
    else still active
        T->>Y: HTTP request (or automation run)
        alt 2xx
            Y-->>T: response
            T->>DB: "success" with status, body, time
        else error, timeout or non-2xx
            T->>DB: "retrying" with next_retry_at,<br/>or "failed" when retries are used up
        end
    end
    T->>DB: Retry: claim due "retrying" executions and call again
```

An execution moves through these states:

```mermaid theme={null}
stateDiagram-v2
    [*] --> scheduled: pre-scheduled
    scheduled --> processing: claimed when due
    scheduled --> skipped: schedule or rule deactivated, or an older missed run
    processing --> success: 2xx
    processing --> retrying: failed, retries left
    processing --> failed: failed, no retries left
    retrying --> processing: retry claimed after retry_delay_seconds
    success --> [*]
    failed --> [*]
    skipped --> [*]
```

* **One run per moment.** A schedule and a run time identify an execution uniquely, so overlapping ticks cannot create or send the same run twice.
* **No replay after an outage.** If the scheduler was down and several runs of a schedule are due at once, only the newest is sent; the older ones are marked `skipped` with `Skipped: newer execution available (heartbeat gap recovery)`.
* **Deactivating cancels planned runs.** Executions already pre-scheduled re-check the schedule and rule when they come due and are marked `skipped` if either was switched off (`Schedule was deactivated before execution`), without any request.
* **`max_retries: 0`** means one attempt. Connection errors, timeouts and non-2xx answers all count as failures.
* **What is sent.** `POST`, `PUT` and `PATCH` send `webhook_payload` as JSON (with `Content-Type: application/json` unless your headers set one); `GET` and `DELETE` send no body. Your `webhook_headers` are sent as given. Scheduled requests are not signed: put a credential for your endpoint in `webhook_headers`.
* **Automation targets** run the automation with no arguments, so every parameter of its `main` needs a default.

### Create a schedule

[Create a webhook schedule](https://docs.callva.io/api-reference/webhook-schedules/create-a-webhook-schedule.md):

```bash theme={null}
curl -X POST "$CALLVA_API/webhook-schedule" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Docs heartbeat",
    "description": "Pings the receiver every five minutes on weekdays",
    "target_type": "webhook",
    "webhook_url": "https://hooks.example.com/heartbeat",
    "webhook_method": "POST",
    "webhook_headers": {"X-Source": "callva-schedule"},
    "webhook_payload": {"job": "sync"},
    "max_retries": 2,
    "retry_delay_seconds": 60,
    "timeout_seconds": 10,
    "rules": [
      {"name": "Weekdays, every 5 minutes", "priority": 10, "action": "run", "days_of_week": [1,2,3,4,5], "execution_interval": 300},
      {"name": "Skip New Year", "priority": 1, "action": "skip", "date_from": "2027-01-01", "date_to": "2027-01-01", "is_exclusive": true}
    ]
  }'
```

```json Response 201 (trimmed) theme={null}
{
  "success": true,
  "message": "Webhook schedule created successfully",
  "data": {
    "id": "01a1c74e-cfb3-7032-bad6-ffa6090fcc15",
    "project_id": "01a1c749-5525-731a-ab9f-61ffbf9e4ef2",
    "name": "Docs heartbeat",
    "target_type": "webhook",
    "automation_id": null,
    "webhook_url": "https://hooks.example.com/heartbeat",
    "webhook_method": "POST",
    "webhook_headers": {"X-Source": "callva-schedule"},
    "webhook_payload": {"job": "sync"},
    "is_active": true,
    "max_retries": 2,
    "retry_delay_seconds": 60,
    "timeout_seconds": 10,
    "rules": [
      {
        "id": "01a1c74e-cfbc-72f5-8701-63ee4505d522",
        "name": "Skip New Year",
        "priority": 1,
        "date_from": "2027-01-01T00:00:00.000000Z",
        "date_to": "2027-01-01T00:00:00.000000Z",
        "days_of_week": null,
        "time_from": null,
        "time_to": null,
        "action": "skip",
        "execution_interval": null,
        "is_exclusive": true,
        "is_active": true
      },
      {
        "id": "01a1c74e-cfba-7121-bd78-57643f0d99ec",
        "name": "Weekdays, every 5 minutes",
        "priority": 10,
        "days_of_week": [1, 2, 3, 4, 5],
        "time_from": null,
        "time_to": null,
        "action": "run",
        "execution_interval": 300,
        "is_exclusive": false,
        "is_active": true,
        ...
      }
    ],
    ...
  }
}
```

> [!WARNING]
>
> Always send `webhook_headers` and `webhook_payload`, as `{}` when you have none. Leaving either out is answered with a `500` (`Not null violation`) and nothing is created.

Schedule names are unique within a project. Header values are stored as given and returned to anyone who can read the schedule, so give `schedules.read` only to roles that may see the credentials you put there.

### Preview a moment

[Preview a webhook schedule](https://docs.callva.io/api-reference/webhook-schedules/preview-a-webhook-schedule.md) tells you which rule governs a moment and whether it runs. It checks the rule conditions, not interval alignment: a moment between two interval ticks still reports `run` if a run rule matches.

```bash theme={null}
curl -X POST "$CALLVA_API/webhook-schedule/01a1c74e-cfb3-7032-bad6-ffa6090fcc15/preview" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"datetime": "2026-11-11T09:00:00Z"}'
```

```json Wednesday 09:00 theme={null}
{
  "success": true,
  "message": "Schedule preview generated successfully",
  "data": {
    "datetime": "2026-11-11T09:00:00+00:00",
    "datetime_in_schedule_tz": "2026-11-11T09:00:00+00:00",
    "timezone": "UTC",
    "matched": true,
    "rule": {
      "id": "01a1c74e-cfba-7121-bd78-57643f0d99ec",
      "name": "Weekdays, every 5 minutes",
      "priority": 10,
      "action": "run",
      "is_exclusive": false
    },
    "action": "run",
    "exclusive_block": false
  }
}
```

```json Sunday 09:00 theme={null}
{
  "success": true,
  "message": "Schedule preview generated successfully",
  "data": {
    "datetime": "2026-11-15T09:00:00+00:00",
    "datetime_in_schedule_tz": "2026-11-15T09:00:00+00:00",
    "timezone": "UTC",
    "matched": false,
    "rule": null,
    "action": "skip",
    "exclusive_block": false
  }
}
```

```json New Year 09:00 theme={null}
{
  "success": true,
  "message": "Schedule preview generated successfully",
  "data": {
    "datetime": "2027-01-01T09:00:00+00:00",
    "datetime_in_schedule_tz": "2027-01-01T09:00:00+00:00",
    "timezone": "UTC",
    "matched": true,
    "rule": {
      "id": "01a1c74e-cfbc-72f5-8701-63ee4505d522",
      "name": "Skip New Year",
      "priority": 1,
      "action": "skip",
      "is_exclusive": true
    },
    "action": "skip",
    "exclusive_block": false
  }
}
```

`exclusive_block: true` means an exclusive rule owns the date but the moment is outside its time window, so the answer is `skip` and no other rule was asked.

### Read the execution history

[List a schedule's executions](https://docs.callva.io/api-reference/webhook-schedules/list-a-schedules-executions.md) lists executions, upcoming (pre-scheduled) ones included. It is ordered by `triggered_at` descending, so runs not yet triggered come first. `per_page` defaults to 50.

```bash theme={null}
curl "$CALLVA_API/webhook-schedule/01a1c74e-cfb3-7032-bad6-ffa6090fcc15/execution?per_page=5" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json"
```

```json Response (trimmed) theme={null}
{
  "success": true,
  "message": "Webhook executions retrieved successfully",
  "data": [
    {
      "id": "01a1c74e-eacb-70e1-b5f5-c98c3629b30d",
      "schedule_id": "01a1c74e-cfb3-7032-bad6-ffa6090fcc15",
      "scheduled_at": "2026-11-10T21:20:00.000000Z",
      "triggered_at": null,
      "matched_rule_id": "01a1c74e-cfba-7121-bd78-57643f0d99ec",
      "matched_rule_name": "Weekdays, every 5 minutes",
      "action_taken": "run",
      "status": "scheduled",
      "request_url": "https://hooks.example.com/heartbeat",
      "request_method": null,
      "response_status_code": null,
      "attempt_number": 0,
      "next_retry_at": null,
      ...
    },
    {
      "id": "01a1c74e-eac5-72b7-bf8d-03daf533a946",
      "schedule_id": "01a1c74e-cfb3-7032-bad6-ffa6090fcc15",
      "scheduled_at": "2026-11-10T21:15:00.000000Z",
      "triggered_at": "2026-11-10T21:15:00.000000Z",
      "matched_rule_id": "01a1c74e-cfba-7121-bd78-57643f0d99ec",
      "matched_rule_name": "Weekdays, every 5 minutes",
      "action_taken": "run",
      "status": "success",
      "request_url": "https://hooks.example.com/heartbeat",
      "request_method": "POST",
      "request_headers": {"X-Source": "callva-schedule", "Content-Type": "application/json"},
      "request_payload": {"job": "sync"},
      "response_status_code": 204,
      "response_body": "",
      "response_time_ms": 18,
      "error_message": null,
      "attempt_number": 0,
      "next_retry_at": null,
      ...
    }
  ],
  "pagination": {"current_page": 1, "last_page": 1, "per_page": 5, "total": 3, "from": 1, "to": 3},
  "meta": {"project_id": "01a1c749-5525-731a-ab9f-61ffbf9e4ef2"}
}
```

Executions keep the request and response bodies they carried. Finished ones (`success`, `failed`, `skipped`) are deleted once older than the organization's `webhook_execution_retention_days` setting: 30 days by default, between 7 and 90. An execution that stays in `processing` means the worker stopped mid-call; nothing reclaims it.

### Update, pause and delete

[Update a webhook schedule](https://docs.callva.io/api-reference/webhook-schedules/update-a-webhook-schedule.md) is a full replacement: send every field you want to keep. Omitted settings return to their defaults (`max_retries` 3, `timeout_seconds` 30, `is_active` true), and omitted rules are deleted. Send a rule's `id` to keep and update it; a rule without an `id` is created.

```bash theme={null}
curl -X PUT "$CALLVA_API/webhook-schedule/01a1c74e-cfb3-7032-bad6-ffa6090fcc15" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Docs heartbeat",
    "target_type": "webhook",
    "webhook_url": "https://hooks.example.com/heartbeat",
    "webhook_method": "POST",
    "webhook_headers": {"X-Source": "callva-schedule"},
    "webhook_payload": {"job": "sync"},
    "is_active": false,
    "rules": [
      {"id": "01a1c74e-cfba-7121-bd78-57643f0d99ec", "name": "Weekdays, every 5 minutes", "priority": 10, "action": "run", "days_of_week": [1,2,3,4,5], "execution_interval": 300}
    ]
  }'
```

```json Response (trimmed) theme={null}
{
  "success": true,
  "message": "Webhook schedule updated successfully",
  "data": {
    "id": "01a1c74e-cfb3-7032-bad6-ffa6090fcc15",
    "is_active": false,
    "max_retries": 3,
    "timeout_seconds": 30,
    "rules": [ { "id": "01a1c74e-cfba-7121-bd78-57643f0d99ec", "name": "Weekdays, every 5 minutes", ... } ],
    ...
  }
}
```

Setting `is_active: false` pauses the schedule: no new runs are pre-scheduled, and runs already planned are marked `skipped` when they come due. [Delete a schedule](https://docs.callva.io/api-reference/webhook-schedules/delete-a-webhook-schedule.md) removes it with its rules and execution history:

```bash theme={null}
curl -X DELETE "$CALLVA_API/webhook-schedule/01a1c74e-cfb3-7032-bad6-ffa6090fcc15" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json"
```

```json theme={null}
{ "success": true, "message": "Webhook schedule deleted successfully" }
```

[List all schedules](https://docs.callva.io/api-reference/webhook-schedules/list-webhook-schedules.md) (`per_page` defaults to 15) and [get one](https://docs.callva.io/api-reference/webhook-schedules/get-a-webhook-schedule.md) return schedules with their rules in priority order.

## Webhook logs

Webhook logs are a project's record of the HTTP exchanges around its calls, kept for debugging. Each entry stores the full request (method, URL, headers with credentials redacted, payload, source IP), the response (status, body, time taken), a processing status, an error message, and the call it relates to.

Entries come from these exchanges:

| `type` | `event_type` | What was recorded |
| - | - | - |
| `call` | `dispatch-request` | An [outbound call request](https://docs.callva.io/api-reference/outbound-calls/place-an-outbound-call.md) you sent, including refused ones. |
| `call` | `room-create`, `agent-dispatch`, `participant-create` | Each step CallVA took with the voice engine to place an outbound call. |
| `call` | `call.dialing`, `call.started`, `call.ended`, `call.recording` | Each report the voice worker sent CallVA about a call, as received. |
| `config_request` | `config-request-webrtc`, `config-request-sip`, `config-request-livekit` | The voice worker asking for an agent's configuration at the start of a call. |
| `config_request` | `config-override` | CallVA calling the agent's custom config URL for a call, with what it answered. |

`processing_status` is `success`, `validation_failed`, `processing_error` or `signature_invalid`. Forwards to your custom webhook URL and scheduled webhook requests are not webhook-log entries; the latter have their own [execution history](#read-the-execution-history).

### List and filter

[List webhook logs](https://docs.callva.io/api-reference/webhook-logs/list-webhook-logs.md), newest first:

```bash theme={null}
curl "$CALLVA_API/webhook-logs?processing_status=validation_failed&event_type=dispatch-request&per_page=10" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json"
```

```json Response (trimmed) theme={null}
{
  "success": true,
  "message": "Webhook logs retrieved successfully",
  "data": [
    {
      "id": "01a1c74e-6218-7367-9c7d-d705c5922bca",
      "tenant_id": "01a1c749-5387-7002-8154-5a7913629e6e",
      "project_id": "01a1c749-5525-731a-ab9f-61ffbf9e4ef2",
      "agent_id": null,
      "type": "call",
      "type_label": "Call",
      "event_type": "dispatch-request",
      "request_method": "POST",
      "request_url": "https://api.callva.io/api/v1/external/call",
      "request_headers": {
        "accept": ["application/json"],
        "content-type": ["application/json"],
        "authorization": ["[REDACTED]"],
        ...
      },
      "request_payload": {"agent_id": "01a1c749-5593-707d-877f-f89da2be547b"},
      "request_ip": "203.0.113.10",
      "response_status": 422,
      "response_body": {
        "error": "validation_error",
        "errors": {"destination": ["The destination field is required."]}
      },
      "response_time_ms": 346,
      "processing_status": "validation_failed",
      "processing_status_label": "Validation Failed",
      "error_message": "Validation failed: {\"destination\":[\"The destination field is required.\"]}",
      "logs": null,
      "related_call_id": null,
      "received_at": "2026-11-10T21:12:45.000000Z",
      "created_at": "2026-11-10T21:12:45.000000Z",
      "updated_at": "2026-11-10T21:12:45.000000Z",
      "agent": null
    }
  ],
  "pagination": {"current_page": 1, "last_page": 1, "per_page": 10, "total": 1, "from": 1, "to": 1}
}
```

| Query parameter | Meaning |
| - | - |
| `search` | Text in the agent id, related call id, event type or error message. |
| `type` | `call` or `config_request`. |
| `event_type` | One event type, for example `call.ended`. |
| `processing_status` | `success`, `validation_failed`, `processing_error`, `signature_invalid`. |
| `agent_id`, `related_call_id` | Entries for one agent or one call. |
| `received_from`, `received_to` | Date range on `received_at`. |
| `response_status`, `response_time_min`, `response_time_max` | HTTP status, and response time in milliseconds. |
| `sort` | `received_at`, `created_at`, `type`, `event_type`, `processing_status`, `response_status` or `response_time_ms`; prefix `-` for descending. Default `-received_at`. |
| `page`, `per_page` | `per_page` defaults to 30, at most 100. |

[List webhook log filters](https://docs.callva.io/api-reference/webhook-logs/list-webhook-log-filters.md) returns the values to offer, including the event types that actually occur in this project:

```bash theme={null}
curl "$CALLVA_API/webhook-logs/filters" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json"
```

```json theme={null}
{
  "success": true,
  "message": "Filter options retrieved successfully",
  "data": {
    "types": [
      {"value": "call", "label": "Call"},
      {"value": "config_request", "label": "Config Request"}
    ],
    "processing_statuses": [
      {"value": "success", "label": "Success"},
      {"value": "validation_failed", "label": "Validation Failed"},
      {"value": "processing_error", "label": "Processing Error"},
      {"value": "signature_invalid", "label": "Signature Invalid"}
    ],
    "event_types": [
      {"value": "dispatch-request", "label": "Dispatch request"}
    ]
  }
}
```

### One call's logs

[List a call's webhook logs](https://docs.callva.io/api-reference/webhook-logs/list-a-calls-webhook-logs.md) takes the same query parameters and answers `404` when the call is not in the project:

```bash theme={null}
curl "$CALLVA_API/calls/01a1c74e-54f2-73f6-8af6-3ef541efe3a0/webhook-logs" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json"
```

```json theme={null}
{
  "success": true,
  "message": "Webhook logs retrieved successfully",
  "data": [],
  "pagination": {"current_page": 1, "last_page": 1, "per_page": 30, "total": 0, "from": null, "to": null}
}
```

This call was created through the API rather than placed by the voice engine, so nothing was exchanged for it. For a placed call you see its dispatch steps, the configuration request and every lifecycle report, in order.

### One entry

[Get a webhook log](https://docs.callva.io/api-reference/webhook-logs/get-a-webhook-log.md) returns one entry with the same fields, plus `related_call` when it names a call:

```bash theme={null}
curl "$CALLVA_API/webhook-logs/01a1c74e-6218-7367-9c7d-d705c5922bca" \
  -H "Authorization: Bearer $CALLVA_TOKEN" \
  -H "Accept: application/json"
```

```json Response (trimmed) theme={null}
{
  "success": true,
  "message": "Webhook log retrieved successfully",
  "data": {
    "id": "01a1c74e-6218-7367-9c7d-d705c5922bca",
    "type": "call",
    "event_type": "dispatch-request",
    "response_status": 422,
    "processing_status": "validation_failed",
    ...
  }
}
```

Request payloads can contain personal data such as phone numbers, and the log keeps them as received. Give `webhook_logs.read` only to roles that should see that. Entries are deleted once older than the organization's `webhook_log_retention_days` setting: 30 days by default, between 7 and 90.

## Related

> [card] [Automations](https://docs.callva.io/guides/automations.md)
>
> Point a call event webhook or a schedule at a script that runs inside CallVA.

> [card] [Calls](https://docs.callva.io/guides/calls.md)
>
> The call record that events update and that webhook logs relate to.


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