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

# Webhooks

> Receive real-time call events on your server with signed HTTP requests.

## Overview

Echo's webhook system lets you register multiple HTTPS endpoints per project. Each endpoint subscribes to specific events, carries its own signing secret, and benefits from automatic retry with a full delivery audit log.

***

## Events

Subscribe any endpoint to one or more of these events:

| Event          | When it fires                  | What's in `data`               |
| -------------- | ------------------------------ | ------------------------------ |
| `call.started` | A call begins                  | `status: "in_progress"`        |
| `call.ended`   | A call terminates (any reason) | `status`, `ended_reason`       |
| `call.summary` | Post-call analysis is ready    | transcript, analysis, duration |

An endpoint with no matching subscription for a fired event is silently skipped.

***

## Creating a Webhook Endpoint

```bash theme={null}
curl -X POST https://api.meetecho.ai/v1/projects/{projectId}/webhooks \
  -H "x-api-key: echo_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/echo",
    "events": ["call.started", "call.ended", "call.summary"]
  }'
```

```json Response theme={null}
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "project_id": "e56b4c35-882d-4604-9944-9763d314ea59",
  "url": "https://your-server.com/webhooks/echo",
  "signing_secret": "whsec_a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
  "status": "active",
  "events": ["call.started", "call.ended", "call.summary"],
  "consecutive_failures": 0,
  "created_at": "2026-04-12T15:51:59.499Z",
  "updated_at": "2026-04-12T15:51:59.499Z"
}
```

<Warning>
  Save the `signing_secret` — use it to verify the `X-Echo-Signature` header on every incoming request. It is always readable by fetching the endpoint, but treat it like a password.
</Warning>

<Tip>
  During development, use a tunnel tool like [ngrok](https://ngrok.com) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) to expose your local server.
</Tip>

***

## Event Payloads

Every delivery sends a JSON body with this envelope:

| Field             | Type   | Description                                                          |
| ----------------- | ------ | -------------------------------------------------------------------- |
| `event_id`        | UUID   | Unique per event occurrence. Use this to deduplicate across retries. |
| `event_name`      | string | One of `call.started`, `call.ended`, `call.summary`                  |
| `customer_id`     | UUID   | The customer involved in the call                                    |
| `notification_id` | UUID   | The notification (scheduled call) that triggered the event           |
| `data`            | object | Event-specific payload — shape varies by event type                  |

<Tabs>
  <Tab title="call.started">
    Fired when a call begins.

    ```json theme={null}
    {
      "data": {
        "status": "in_progress"
      },
      "event_id": "0785025f-bd96-46df-ad11-7021b8a7a4eb",
      "event_name": "call.started",
      "customer_id": "8cc6199f-9466-46f2-9db7-936f3c2c1c0b",
      "notification_id": "b612d95c-0941-4e4a-8a1e-b8da7baf82a1"
    }
    ```

    ### `data` fields

    | Field    | Type   | Description            |
    | -------- | ------ | ---------------------- |
    | `status` | string | Always `"in_progress"` |
  </Tab>

  <Tab title="call.ended">
    Fired when a call terminates for any reason.

    ```json theme={null}
    {
      "data": {
        "status": "completed",
        "ended_reason": "customer-ended-call"
      },
      "event_id": "bcfd8750-6f3c-4163-84ca-b3832809bd83",
      "event_name": "call.ended",
      "customer_id": "8cc6199f-9466-46f2-9db7-936f3c2c1c0b",
      "notification_id": "b612d95c-0941-4e4a-8a1e-b8da7baf82a1"
    }
    ```

    ### `data` fields

    | Field          | Type   | Description                           |
    | -------------- | ------ | ------------------------------------- |
    | `status`       | string | `"completed"` or `"failed"`           |
    | `ended_reason` | string | Why the call ended — see values below |

    ### `ended_reason` values

    | Value                  | Meaning                                       |
    | ---------------------- | --------------------------------------------- |
    | `assistant-ended-call` | The AI completed the conversation and hung up |
    | `customer-ended-call`  | The caller hung up                            |
    | `max-duration-reached` | The call hit `max_duration_seconds`           |
    | `no-answer`            | The call went unanswered                      |
    | `failed`               | Technical failure during the call             |
  </Tab>

  <Tab title="call.summary">
    Fired after the call ends, once analysis is complete. Contains the full transcript and any structured output from the agent's `output_schema`.

    ```json theme={null}
    {
      "data": {
        "analysis": {
          "confirmed": true,
          "rescheduling": false,
          "preferred_date": null,
          "preferred_time": null
        },
        "ended_at": "2026-04-12T15:54:24.323Z",
        "started_at": "2026-04-12T15:51:59.499Z",
        "transcript": "AI: Hi Sarah, this is Echo Reminders calling on behalf of Greenfield Clinic. I'm reaching out to remind you about your appointment on Monday, April 14 at 10:00 AM. Are you still able to make it?\nCustomer: Yes, I'll be there.\nAI: Perfect, we'll see you then. Have a great day!",
        "ended_reason": "customer-ended-call",
        "duration_seconds": 28.4
      },
      "event_id": "cc66d0a2-7cfd-481f-9826-596e2bb9d58f",
      "event_name": "call.summary",
      "customer_id": "8cc6199f-9466-46f2-9db7-936f3c2c1c0b",
      "notification_id": "b612d95c-0941-4e4a-8a1e-b8da7baf82a1"
    }
    ```

    ### `data` fields

    | Field              | Type           | Description                                                                                                                       |
    | ------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------- |
    | `analysis`         | object \| null | Structured output from the agent's `output_schema`. Shape depends on your agent definition. `null` if no `output_schema` was set. |
    | `started_at`       | ISO 8601       | When the call started                                                                                                             |
    | `ended_at`         | ISO 8601       | When the call ended                                                                                                               |
    | `duration_seconds` | number         | Total call duration in seconds                                                                                                    |
    | `transcript`       | string         | Full call transcript                                                                                                              |
    | `ended_reason`     | string         | Why the call ended (same values as `call.ended`)                                                                                  |
  </Tab>
</Tabs>

***

## Request Headers

Every delivery includes these headers:

| Header             | Description                                           |
| ------------------ | ----------------------------------------------------- |
| `X-Echo-Signature` | `sha256=<hmac-sha256-hex>` — verify before processing |
| `X-Echo-Event`     | The event name, e.g. `call.summary`                   |
| `X-Echo-Delivery`  | The `event_id` for this delivery                      |
| `X-Echo-Timestamp` | Unix timestamp (seconds) when the delivery was sent   |

Use `X-Echo-Timestamp` to reject replayed requests older than a few minutes.

***

## Verifying Signatures

Echo signs the raw request body with HMAC-SHA256 using your endpoint's `signing_secret`. **Always verify the signature before processing a webhook.** This prevents spoofed requests from triggering actions in your system.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  app.post('/webhooks/echo', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = crypto
  .createHmac('sha256', process.env.ECHO_SIGNING_SECRET)
  .update(req.body) // raw Buffer — not parsed JSON
  .digest('hex');

  if (`sha256=${sig}` !== req.headers['x-echo-signature']) {
  return res.status(401).send('Invalid signature');
  }

  const payload = JSON.parse(req.body);
  const { event_name, data } = payload;

  if (event_name === 'call.summary') {
  console.log(data.analysis);
  }

  res.sendStatus(200);
  });

  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import os
  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.route('/webhooks/echo', methods=['POST'])
  def webhook():
      secret = os.environ['ECHO_SIGNING_SECRET'].encode()
      raw_body = request.get_data()

      expected = 'sha256=' + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
      received = request.headers.get('X-Echo-Signature', '')

      if not hmac.compare_digest(expected, received):
          abort(401)

      payload = request.get_json()
      event_name = payload.get('event_name')

      if event_name == 'call.summary':
          print(payload['data']['analysis'])

      return '', 200
  ```

  ```typescript TypeScript (Express) theme={null}
  import crypto from 'crypto';
  import express from 'express';

  const router = express.Router();

  router.post(
    '/webhooks/echo',
    express.raw({ type: 'application/json' }),
    (req, res) => {
      const sig = crypto
        .createHmac('sha256', process.env.ECHO_SIGNING_SECRET!)
        .update(req.body as Buffer)
        .digest('hex');

      if (`sha256=${sig}` !== req.headers['x-echo-signature']) {
        return res.status(401).send('Invalid signature');
      }

      const payload = JSON.parse(req.body.toString());
      const { event_name, data } = payload;

      if (event_name === 'call.summary') {
        console.log(data.analysis);
      }

      res.sendStatus(200);
    }
  );
  ```
</CodeGroup>

<Warning>
  Use the **raw request body** for signature verification — before any JSON parsing. Re-serializing parsed JSON can alter whitespace and break the HMAC match.
</Warning>

***

## Retry & Failure Alerting

Echo automatically retries failed deliveries up to **4 attempts total** with exponential backoff:

| Attempt | Timing                           |
| ------- | -------------------------------- |
| 1       | Immediate                        |
| 2       | 1 minute after attempt 1 fails   |
| 3       | 5 minutes after attempt 2 fails  |
| 4       | 30 minutes after attempt 3 fails |

A delivery is considered failed if your server returns a non-`2xx` response, times out (5 s per attempt), or is unreachable. After all 4 attempts fail, the delivery is marked `failed` and no further automatic retries are scheduled.

After **4 consecutive delivery failures** on the same endpoint, Echo sends one email alert to all business members.

<Note>
  Use `event_id` as your idempotency key — it is identical across all retry attempts for the same event occurrence.
</Note>

***

## Delivery Logs

Every HTTP attempt is logged. Use the delivery log API to diagnose failures and trigger manual retries.

```bash theme={null}
# All deliveries across the project (paginated)
GET /v1/projects/{projectId}/webhooks/deliveries

# Deliveries for a specific endpoint
GET /v1/projects/{projectId}/webhooks/{webhookId}/deliveries

# Single delivery with all per-attempt details
GET /v1/projects/{projectId}/webhooks/{webhookId}/deliveries/{deliveryId}

# Manually retry a failed delivery
POST /v1/projects/{projectId}/webhooks/{webhookId}/deliveries/{deliveryId}/retry
```

Filter delivery lists by `status` (`pending`, `retrying`, `success`, `failed`) and `event_name`. Paginate with `limit` + `cursor`.

***

## Managing Endpoints

```bash theme={null}
# Update URL, events, or enable/disable
PATCH /v1/projects/{projectId}/webhooks/{webhookId}
-d '{ "status": "disabled" }'

# Rotate the signing secret
POST /v1/projects/{projectId}/webhooks/{webhookId}/secret/rotate

# Delete an endpoint (cascades all delivery history)
DELETE /v1/projects/{projectId}/webhooks/{webhookId}
```

### Safe secret rotation

<Steps>
  <Step title="Rotate the secret">
    Call `POST /v1/projects/{projectId}/webhooks/{webhookId}/secret/rotate` and copy the returned `signing_secret`.
  </Step>

  <Step title="Deploy the new secret">
    Update `ECHO_SIGNING_SECRET` in your environment and redeploy before proceeding.
  </Step>

  <Step title="Verify delivery">
    Trigger a test notification and confirm your server accepts the new signature.
  </Step>
</Steps>

<Warning>
  The new secret takes effect immediately. Update your server before rotating — there is no grace period.
</Warning>
