> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.agentphone.ai/documentation/guides/webhooks/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. > **Note** > > 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 } } ``` > **Note** > > `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=`) | | `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 | > **Warning** > > 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. | > **Warning** > > 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. | > **Warning** > > 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 } ``` > Configure webhook endpoints and receive real-time event notifications