> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.agentphone.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.agentphone.ai/_mcp/server.

# Webhooks

> Configure webhook endpoints and receive real-time event notifications

Webhooks deliver real-time notifications to your server when events occur in your AgentPhone project. Each project has a single master webhook endpoint that receives all events.

You can also configure per-agent webhooks that override the project default for a specific agent. See [Per-agent webhooks](#per-agent-webhooks) below.

## Events

All inbound messages (SMS, iMessage, WhatsApp, and voice) are delivered as a unified `agent.message` event. The `channel` field tells you the source. When a call ends, `agent.call_ended` delivers the full transcript and call analysis. Reactions trigger `agent.reaction`.

| Event              | Channel                              | Description                                                               |
| ------------------ | ------------------------------------ | ------------------------------------------------------------------------- |
| `agent.message`    | `sms`, `mms`, `imessage`, `whatsapp` | An inbound message was received on one of your numbers                    |
| `agent.message`    | `voice`                              | A voice transcript is ready from an active call                           |
| `agent.reaction`   | `imessage`, `whatsapp`               | Someone reacted to a message (tapback on iMessage, emoji on WhatsApp)     |
| `agent.call_ended` | `voice`                              | A voice call has ended — includes full transcript, duration, and analysis |

## Webhook payload

Each webhook delivery includes the following structure. SMS, iMessage, and voice share the same top-level format:

```json
{
  "event": "agent.message",
  "channel": "sms",
  "timestamp": "2025-01-15T12:00:00Z",
  "agentId": "agt_abc123",
  "data": {
    "conversationId": "conv_def456",
    "numberId": "num_xyz789",
    "from": "+15559876543",
    "to": "+15551234567",
    "message": "Hi, I need help with my order",
    "mediaUrl": null,
    "direction": "inbound",
    "receivedAt": "2025-01-15T12:00:00Z"
  },
  "conversationState": {
    "customerName": "Jane Doe",
    "orderId": "ORD-12345"
  },
  "recentHistory": [
    { "content": "Hello", "direction": "inbound", "channel": "sms", "at": "2025-01-15T11:59:00Z" }
  ]
}
```

### `agent.message` fields by channel

`agent.message` keeps one envelope and changes only the `data` shape by channel:

| Channel                  | Primary content fields in `data`                                                       |
| ------------------------ | -------------------------------------------------------------------------------------- |
| `sms`, `mms`, `imessage` | `message`, `mediaUrl`, `from`, `to`, `direction`, `receivedAt`                         |
| `whatsapp`               | the same fields, plus `replyTo` when the message is a quote reply or a button/list tap |
| `voice`                  | `transcript`, `confidence`, `status`, `from`, `to`, `direction`                        |

### WhatsApp

Inbound WhatsApp messages arrive as the same `agent.message` event with `channel: "whatsapp"`. Nothing extra to configure once a WhatsApp Business Account is connected.

**Button and list taps** arrive as ordinary inbound messages. `data.message` is the label the customer tapped, so code that already handles text handles taps for free:

```json
{
  "event": "agent.message",
  "channel": "whatsapp",
  "data": {
    "from": "+13106222100",
    "to": "+14788348706",
    "message": "Buy GTX",
    "direction": "inbound",
    "replyTo": {
      "messageId": "cmt0vbww0000dog1wezu1jh6u",
      "message": "*Trail Runner GTX* — $139\nLightweight, waterproof, size 8-13 in stock.",
      "mediaUrls": ["https://api.agentphone.ai/v1/messages/cmt0vbww0000dog1wezu1jh6u/media"]
    }
  }
}
```

The `replyTo` block is what makes taps genuinely useful. It points at the message that offered the choice, so when you have sent five product cards each with a **Buy** button, the label alone cannot tell you which product they meant but `replyTo.messageId` can. It is present on list taps and on customer quote replies too, and omitted entirely on an ordinary message. `mediaUrls` carries the quoted message's attachments when it had any (a second attachment would be `?index=1`), and is an empty array otherwise.

**Reactions** on WhatsApp arrive as `agent.reaction`, the same event iMessage tapbacks use, with the emoji in `reactionType`.

### Group chats

Group chats are an iMessage feature. When your number is part of a group thread, inbound group messages arrive as the same `agent.message` event (`channel: "imessage"`), with two additions to `data`:

* `data.group` — the group roster and metadata
* `data.senderIdentifier` — which member sent this specific message

For one-to-one conversations these two fields are **omitted entirely** (not `null`), so the presence of `data.group` is a reliable "is this a group?" check. SMS numbers cannot participate in group threads.

```json
{
  "event": "agent.message",
  "channel": "imessage",
  "timestamp": "2025-01-15T12:00:00Z",
  "agentId": "agt_abc123",
  "data": {
    "conversationId": "conv_def456",
    "numberId": "num_xyz789",
    "from": "+15554444444",
    "to": "+15551234567",
    "message": "Are we still on for Friday?",
    "mediaUrl": null,
    "direction": "inbound",
    "receivedAt": "2025-01-15T12:00:00Z",
    "senderIdentifier": "+15554444444",
    "group": {
      "isGroup": true,
      "groupId": "grp_abc123",
      "groupName": "Trip Planning",
      "groupIconUrl": null,
      "participants": [
        { "identifier": "+15554444444", "name": "Alice" },
        { "identifier": "+15555555555", "name": null }
      ]
    }
  },
  "conversationState": null,
  "recentHistory": [
    { "content": "Sounds good", "direction": "inbound", "channel": "imessage", "at": "2025-01-15T11:59:00Z", "senderIdentifier": "+15555555555" }
  ]
}
```

| Field                     | Description                                                                                                                                                                                  |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data.group.isGroup`      | Always `true` when present. Stable for the life of the thread.                                                                                                                               |
| `data.group.groupId`      | Provider-assigned group identifier (`grp_...`). Stable, and the value you reply to.                                                                                                          |
| `data.group.groupName`    | Group display name when known, else `null`.                                                                                                                                                  |
| `data.group.groupIconUrl` | Group icon URL when known, else `null`.                                                                                                                                                      |
| `data.group.participants` | Roster as last seen: an array of `{ identifier, name }`. Refreshed on every inbound message as members join or leave.                                                                        |
| `data.senderIdentifier`   | The member who sent this message. Use this for per-message attribution rather than reconstructing it from the roster. Each `recentHistory` entry in a group also carries `senderIdentifier`. |

The webhook is self-contained: you do not need to fetch the conversation before routing or replying. To reply to the group, send to the `groupId` (not `data.from`, which would start a one-to-one with that member): call [`POST /v1/messages`](/documentation/guides/messages) with `to_number` set to the `grp_...` value.

For voice events, the `data` field contains the transcript instead:

```json
{
  "event": "agent.message",
  "channel": "voice",
  "timestamp": "2025-01-15T14:00:05Z",
  "agentId": "agt_abc123",
  "data": {
    "callId": "call_abc123",
    "numberId": "num_xyz789",
    "from": "+15559876543",
    "to": "+15551234567",
    "status": "in-progress",
    "transcript": "I need help with my order",
    "confidence": 0.95,
    "direction": "inbound"
  },
  "conversationState": null,
  "recentHistory": [
    { "content": "Hello, how can I help?", "direction": "outbound", "channel": "voice", "at": "2025-01-15T14:00:00Z" }
  ]
}
```

When a call ends, `agent.call_ended` delivers the full transcript (not limited by `contextLimit`), call duration, and optional analysis:

```json
{
  "event": "agent.call_ended",
  "channel": "voice",
  "timestamp": "2025-01-15T14:05:30Z",
  "agentId": "agt_abc123",
  "data": {
    "callId": "call_ghi012",
    "numberId": "num_xyz789",
    "from": "+15559876543",
    "to": "+15551234567",
    "direction": "inbound",
    "status": "completed",
    "startedAt": "2025-01-15T14:00:00Z",
    "endedAt": "2025-01-15T14:05:30Z",
    "durationSeconds": 330,
    "disconnectionReason": "agent_hangup",
    "transcript": [
      { "role": "agent", "content": "Hello! How can I help you today?" },
      { "role": "user", "content": "I need help with my order." },
      { "role": "agent", "content": "Sure! Could you provide your order number?" }
    ],
    "summary": "Customer called about an order inquiry. Agent helped locate the order.",
    "userSentiment": "Positive",
    "callSuccessful": true
  }
}
```

`agent.call_ended` is fire-and-forget — it does not expect a response body. Return `200 OK` to acknowledge receipt.

When someone reacts to an iMessage your agent sent, `agent.reaction` is delivered:

```json
{
  "event": "agent.reaction",
  "channel": "imessage",
  "timestamp": "2025-01-15T12:01:00Z",
  "agentId": "agt_abc123",
  "data": {
    "conversationId": "conv_def456",
    "numberId": "num_xyz789",
    "reactionType": "love",
    "fromNumber": "+15559876543",
    "direction": "inbound",
    "messageId": "msg_001",
    "messageBody": "Hey! How can I help?",
    "messageMediaUrl": null,
    "createdAt": "2025-01-15T12:01:00Z"
  }
}
```

The `reactionType` is one of: `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`.

### Voice webhook responses

For voice events, your webhook must return a JSON object (`{...}`) that tells the agent what to say. Non-object responses (numbers, strings, arrays) are ignored and the caller hears silence.

**Streaming response (recommended):** Return `Content-Type: application/x-ndjson` with newline-delimited JSON chunks. TTS starts speaking on the first chunk while your server continues processing.

```
{"text": "Let me check that for you.", "interim": true}
{"text": "I found 3 results for your order."}
```

Mark interim chunks with `"interim": true` — the final chunk (without `interim`) closes the turn. Voice webhooks have a **30-second default timeout** (configurable from 5–120 seconds per webhook) — always stream an interim chunk before doing slow work like LLM tool calls.

**Simple response:** Return a single JSON object for instant replies.

```json
{ "text": "How can I help you?" }
```

| Field          | Type    | Description                                                                                                                                                                                                                                                                   |
| -------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text`         | string  | Text to speak to the caller                                                                                                                                                                                                                                                   |
| `hangup`       | boolean | Set to `true` to end the call after speaking                                                                                                                                                                                                                                  |
| `action`       | string  | `"transfer"` to cold-transfer the call (requires `transferNumber` on the agent), `"hangup"` to end it                                                                                                                                                                         |
| `digits`       | string  | DTMF digits to press on the keypad (e.g. `"1"`, `"123"`, `"1*#"`). Used to navigate IVR menus and automated phone systems. Aliases: `press_digit`, `dtmf`                                                                                                                     |
| `send_message` | object  | Send the call counterparty an SMS during the call. Shape: `{"body": "..."}`. The recipient defaults to the other party on this call; only pass `to` if your account is allowed to text arbitrary numbers. Aliases: `send_sms`, or `{"action": "send_message", "body": "..."}` |
| `interim`      | boolean | NDJSON only — marks a chunk as interim so TTS speaks it while the turn stays open                                                                                                                                                                                             |

Example: confirming an appointment by text while the agent stays on the call.

```json
{
  "text": "Sending you a confirmation now.",
  "send_message": { "body": "Confirmed: Tue 3pm with Dr. Lee. Reply STOP to cancel." }
}
```

## Security

Each webhook delivery includes these headers:

| Header                | Description                                                   |
| --------------------- | ------------------------------------------------------------- |
| `X-Webhook-Signature` | HMAC-SHA256 signature (`sha256=<hex_digest>`)                 |
| `X-Webhook-Timestamp` | Unix timestamp of the delivery (for replay-attack protection) |
| `X-Webhook-ID`        | Unique delivery ID (use for idempotency)                      |
| `X-Webhook-Event`     | Event type (e.g. `agent.message`) for fast filtering          |

The signature is computed over the **timestamp + body**: the signed string is `{timestamp}.{raw_body}`, hashed with HMAC-SHA256 using your webhook secret. Always verify the timestamp is within 5 minutes to prevent replay attacks.

### Verification example (Node.js)

```javascript
const crypto = require('crypto');

function verifyWebhook(rawBody, signature, timestamp, secret) {
  // Reject requests older than 5 minutes
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) return false;
  const signedString = timestamp + '.' + rawBody;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedString)
    .digest('hex');
  return signature === `sha256=${expected}`;
}

// Usage in Express:
// const sig = req.headers['x-webhook-signature'];
// const ts = req.headers['x-webhook-timestamp'];
// verifyWebhook(req.body, sig, ts, WEBHOOK_SECRET);
```

### Verification example (Python)

```python
import hmac
import hashlib
import time

def verify_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    # Reject requests older than 5 minutes to prevent replay attacks
    if abs(time.time() - int(timestamp)) > 300:
        return False
    signed_string = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(
        secret.encode(), signed_string, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", signature)

# Usage in Flask/FastAPI:
# signature = request.headers["X-Webhook-Signature"]
# timestamp = request.headers["X-Webhook-Timestamp"]
# verify_webhook(request.body, signature, timestamp, WEBHOOK_SECRET)
```

## Retry behavior

If your webhook endpoint fails or doesn't respond, we automatically retry delivery with exponential backoff:

| Attempt | Delay      | Description              |
| ------- | ---------- | ------------------------ |
| 1       | Immediate  | Initial delivery attempt |
| 2       | 5 minutes  | First retry              |
| 3       | 30 minutes | Second retry             |
| 4       | 2 hours    | Third retry              |
| 5       | 6 hours    | Fourth retry             |
| 6       | 12 hours   | Final retry              |

After 5 retries (6 total attempts), the delivery is marked as failed. You can view failed deliveries via `GET /v1/webhooks/deliveries`. Always return `200 OK` quickly to avoid retries — process webhooks asynchronously if needed.

### Handling duplicate deliveries

Due to retries, your endpoint may receive the same webhook multiple times. Use the `X-Webhook-ID` header for idempotency:

```python
processed_webhooks = set()  # in production, use Redis or a database

@app.route('/webhook', methods=['POST'])
def webhook():
    webhook_id = request.headers.get('X-Webhook-ID')
    if webhook_id in processed_webhooks:
        return 'OK', 200  # already processed

    # ... process the webhook ...

    processed_webhooks.add(webhook_id)
    return 'OK', 200
```

```javascript
const processed = new Set(); // in production, use Redis or a database

app.post('/webhook', (req, res) => {
  const webhookId = req.headers['x-webhook-id'];
  if (processed.has(webhookId)) return res.status(200).send('OK');

  // ... process the webhook ...

  processed.add(webhookId);
  res.status(200).send('OK');
});
```

## Conversation state

Store custom metadata on conversations to persist context across messages. This state is included in every webhook payload as `conversationState`.

```bash
curl -X PATCH "https://api.agentphone.ai/v1/conversations/conv_abc123" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "metadata": {
      "customerName": "John Smith",
      "orderId": "ORD-12345",
      "topic": "shipping"
    }
  }'
```

This metadata appears in subsequent webhook payloads as `conversationState`, enabling your AI backend to maintain context across messages without managing state yourself.

## Create or update webhook

Configure the webhook endpoint for your project. Each project can have one active master webhook. If a webhook already exists, it will be updated.

```
POST /v1/webhooks
```

### Request body

| Field          | Type            | Required | Description                                                                                                |
| -------------- | --------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `url`          | string          | Yes      | The HTTPS URL to receive webhook deliveries                                                                |
| `contextLimit` | integer or null | No       | Number of recent messages to include in webhook payloads (0-50, default: 10). Set to 0 to disable history. |
| `timeout`      | integer or null | No       | Max seconds to wait for a webhook response (5-120, default: 30). Applies to voice webhook requests.        |

A new signing secret is generated each time you create or update a webhook. Save the `secret` value from the response.

### Example

```bash
curl -X POST "https://api.agentphone.ai/v1/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-server.com/webhook", "contextLimit": 10}'
```

```json
{
  "id": "wh_mno678",
  "url": "https://your-server.com/webhook",
  "secret": "whsec_abc123...",
  "status": "active",
  "contextLimit": 10,
  "timeout": 30,
  "createdAt": "2025-01-15T11:00:00Z"
}
```

## Get webhook

Get the current webhook configuration for your project. Returns `null` if no webhook is configured.

```
GET /v1/webhooks
```

## Delete webhook

Remove the master webhook configuration. Events will no longer be delivered.

```
DELETE /v1/webhooks
```

### Example

```bash
curl -X DELETE "https://api.agentphone.ai/v1/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

## Webhook deliveries

View the delivery history for your master webhook to monitor delivery status and debug issues.

```
GET /v1/webhooks/deliveries
```

### Query parameters

| Parameter | Type    | Required | Default | Description                               |
| --------- | ------- | -------- | ------- | ----------------------------------------- |
| `limit`   | integer | No       | 50      | Number of results to return (max 100)     |
| `offset`  | integer | No       | 0       | Number of results to skip (min 0)         |
| `hours`   | integer | No       | null    | Optional lookback window in hours (1-168) |

### Example

```bash
curl -X GET "https://api.agentphone.ai/v1/webhooks/deliveries?limit=10&offset=0" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
{
  "items": [
    {
      "id": "del_pqr901",
      "messageId": "msg_001",
      "eventType": "agent.message",
      "status": "success",
      "httpStatus": 200,
      "errorMessage": null,
      "attemptCount": 1,
      "lastAttemptAt": "2025-01-15T12:00:01Z",
      "nextRetryAt": null,
      "createdAt": "2025-01-15T12:00:01Z"
    }
  ],
  "total": 124,
  "offset": 0,
  "limit": 10
}
```

## Test webhook

Send a test webhook to verify your endpoint is working correctly. This sends a fake message payload to your configured URL.

```
POST /v1/webhooks/test
```

### Example

```bash
curl -X POST "https://api.agentphone.ai/v1/webhooks/test" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
{
  "success": true,
  "httpStatus": 200,
  "errorMessage": null
}
```

## Per-agent webhooks

In addition to the project-level default webhook, you can configure an individual webhook endpoint for each agent. When a per-agent webhook is set, it **overrides** the project default for that agent's events — the event is delivered to exactly one endpoint, never both. This is useful when you run multiple agents and want to route their events to different backend services or pipelines.

### How routing works

When an event occurs (e.g., an inbound message to an agent's number):

1. If the agent has its **own webhook** configured, the event is delivered **only** to the agent's webhook.
2. If the agent does **not** have its own webhook, the event is delivered to the **project default webhook**.

Events are never duplicated across both endpoints. Per-agent webhooks use the same payload format, signature verification, and retry behavior described above.

### Create or update an agent webhook

Register or update a webhook URL for a specific agent.

```
POST /v1/agents/{agent_id}/webhook
```

| Field          | Type            | Required | Description                                                                                                |
| -------------- | --------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `url`          | string          | Yes      | The HTTPS URL to receive webhook deliveries                                                                |
| `contextLimit` | integer or null | No       | Number of recent messages to include in webhook payloads (0-50, default: 10). Set to 0 to disable history. |
| `timeout`      | integer or null | No       | Max seconds to wait for a webhook response (5-120, default: 30). Applies to voice webhook requests.        |

A new signing secret is generated each time you create or update a webhook. Save the `secret` value from the response.

```bash
curl -X POST "https://api.agentphone.ai/v1/agents/agt_abc123/webhook" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-server.com/agent-webhook", "contextLimit": 5}'
```

```json
{
  "id": "wh_xyz789",
  "url": "https://your-server.com/agent-webhook",
  "secret": "whsec_def456...",
  "status": "active",
  "contextLimit": 5,
  "timeout": 30,
  "createdAt": "2025-01-15T11:00:00Z"
}
```

### Get an agent webhook

Get the webhook configuration for a specific agent. Returns `null` if no webhook is configured.

```
GET /v1/agents/{agent_id}/webhook
```

```bash
curl -X GET "https://api.agentphone.ai/v1/agents/agt_abc123/webhook" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Delete an agent webhook

Remove the webhook for a specific agent. Events revert to the project default webhook after deletion.

```
DELETE /v1/agents/{agent_id}/webhook
```

```bash
curl -X DELETE "https://api.agentphone.ai/v1/agents/agt_abc123/webhook" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Agent webhook deliveries

View recent delivery attempts for a specific agent's webhook.

```
GET /v1/agents/{agent_id}/webhook/deliveries
```

| Parameter | Type    | Required | Default | Description                           |
| --------- | ------- | -------- | ------- | ------------------------------------- |
| `limit`   | integer | No       | 50      | Number of results to return (max 100) |

```bash
curl -X GET "https://api.agentphone.ai/v1/agents/agt_abc123/webhook/deliveries?limit=10" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
[
  {
    "id": "del_abc123",
    "messageId": "msg_001",
    "eventType": "agent.message",
    "status": "success",
    "httpStatus": 200,
    "errorMessage": null,
    "attemptCount": 1,
    "lastAttemptAt": "2025-01-15T12:00:01Z",
    "nextRetryAt": null,
    "createdAt": "2025-01-15T12:00:01Z"
  }
]
```

### Test an agent webhook

Send a test webhook to verify the agent's endpoint is working correctly.

```
POST /v1/agents/{agent_id}/webhook/test
```

```bash
curl -X POST "https://api.agentphone.ai/v1/agents/agt_abc123/webhook/test" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
{
  "success": true,
  "httpStatus": 200,
  "errorMessage": null
}
```