Webhooks

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 below.

Events

All inbound messages (SMS, iMessage, 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. iMessage reactions trigger agent.reaction.

EventChannelDescription
agent.messagesms, mms, imessageAn inbound message was received on one of your numbers
agent.messagevoiceA voice transcript is ready from an active call
agent.reactionimessageSomeone reacted to a message with a tapback (iMessage only)
agent.call_endedvoiceA 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:

1{
2 "event": "agent.message",
3 "channel": "sms",
4 "timestamp": "2025-01-15T12:00:00Z",
5 "agentId": "agt_abc123",
6 "data": {
7 "conversationId": "conv_def456",
8 "numberId": "num_xyz789",
9 "from": "+15559876543",
10 "to": "+15551234567",
11 "message": "Hi, I need help with my order",
12 "mediaUrl": null,
13 "direction": "inbound",
14 "receivedAt": "2025-01-15T12:00:00Z"
15 },
16 "conversationState": {
17 "customerName": "Jane Doe",
18 "orderId": "ORD-12345"
19 },
20 "recentHistory": [
21 { "content": "Hello", "direction": "inbound", "channel": "sms", "at": "2025-01-15T11:59:00Z" }
22 ]
23}

agent.message fields by channel

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

ChannelPrimary content fields in data
sms, mms, imessagemessage, mediaUrl, from, to, direction, receivedAt
voicetranscript, confidence, status, from, to, direction

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.

1{
2 "event": "agent.message",
3 "channel": "imessage",
4 "timestamp": "2025-01-15T12:00:00Z",
5 "agentId": "agt_abc123",
6 "data": {
7 "conversationId": "conv_def456",
8 "numberId": "num_xyz789",
9 "from": "+15554444444",
10 "to": "+15551234567",
11 "message": "Are we still on for Friday?",
12 "mediaUrl": null,
13 "direction": "inbound",
14 "receivedAt": "2025-01-15T12:00:00Z",
15 "senderIdentifier": "+15554444444",
16 "group": {
17 "isGroup": true,
18 "groupId": "grp_abc123",
19 "groupName": "Trip Planning",
20 "groupIconUrl": null,
21 "participants": [
22 { "identifier": "+15554444444", "name": "Alice" },
23 { "identifier": "+15555555555", "name": null }
24 ]
25 }
26 },
27 "conversationState": null,
28 "recentHistory": [
29 { "content": "Sounds good", "direction": "inbound", "channel": "imessage", "at": "2025-01-15T11:59:00Z", "senderIdentifier": "+15555555555" }
30 ]
31}
FieldDescription
data.group.isGroupAlways true when present. Stable for the life of the thread.
data.group.groupIdProvider-assigned group identifier (grp_...). Stable, and the value you reply to.
data.group.groupNameGroup display name when known, else null.
data.group.groupIconUrlGroup icon URL when known, else null.
data.group.participantsRoster as last seen: an array of { identifier, name }. Refreshed on every inbound message as members join or leave.
data.senderIdentifierThe 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 with to_number set to the grp_... value.

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

1{
2 "event": "agent.message",
3 "channel": "voice",
4 "timestamp": "2025-01-15T14:00:05Z",
5 "agentId": "agt_abc123",
6 "data": {
7 "callId": "call_abc123",
8 "numberId": "num_xyz789",
9 "from": "+15559876543",
10 "to": "+15551234567",
11 "status": "in-progress",
12 "transcript": "I need help with my order",
13 "confidence": 0.95,
14 "direction": "inbound"
15 },
16 "conversationState": null,
17 "recentHistory": [
18 { "content": "Hello, how can I help?", "direction": "outbound", "channel": "voice", "at": "2025-01-15T14:00:00Z" }
19 ]
20}

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

1{
2 "event": "agent.call_ended",
3 "channel": "voice",
4 "timestamp": "2025-01-15T14:05:30Z",
5 "agentId": "agt_abc123",
6 "data": {
7 "callId": "call_ghi012",
8 "numberId": "num_xyz789",
9 "from": "+15559876543",
10 "to": "+15551234567",
11 "direction": "inbound",
12 "status": "completed",
13 "startedAt": "2025-01-15T14:00:00Z",
14 "endedAt": "2025-01-15T14:05:30Z",
15 "durationSeconds": 330,
16 "disconnectionReason": "agent_hangup",
17 "transcript": [
18 { "role": "agent", "content": "Hello! How can I help you today?" },
19 { "role": "user", "content": "I need help with my order." },
20 { "role": "agent", "content": "Sure! Could you provide your order number?" }
21 ],
22 "summary": "Customer called about an order inquiry. Agent helped locate the order.",
23 "userSentiment": "Positive",
24 "callSuccessful": true
25 }
26}

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:

1{
2 "event": "agent.reaction",
3 "channel": "imessage",
4 "timestamp": "2025-01-15T12:01:00Z",
5 "agentId": "agt_abc123",
6 "data": {
7 "conversationId": "conv_def456",
8 "numberId": "num_xyz789",
9 "reactionType": "love",
10 "fromNumber": "+15559876543",
11 "direction": "inbound",
12 "messageId": "msg_001",
13 "messageBody": "Hey! How can I help?",
14 "messageMediaUrl": null,
15 "createdAt": "2025-01-15T12:01:00Z"
16 }
17}

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.

1{ "text": "How can I help you?" }
FieldTypeDescription
textstringText to speak to the caller
hangupbooleanSet to true to end the call after speaking
actionstring"transfer" to cold-transfer the call (requires transferNumber on the agent), "hangup" to end it
digitsstringDTMF 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_messageobjectSend 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": "..."}
interimbooleanNDJSON 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.

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

Security

Each webhook delivery includes these headers:

HeaderDescription
X-Webhook-SignatureHMAC-SHA256 signature (sha256=<hex_digest>)
X-Webhook-TimestampUnix timestamp of the delivery (for replay-attack protection)
X-Webhook-IDUnique delivery ID (use for idempotency)
X-Webhook-EventEvent 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)

1const crypto = require('crypto');
2
3function verifyWebhook(rawBody, signature, timestamp, secret) {
4 // Reject requests older than 5 minutes
5 if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) return false;
6 const signedString = timestamp + '.' + rawBody;
7 const expected = crypto
8 .createHmac('sha256', secret)
9 .update(signedString)
10 .digest('hex');
11 return signature === `sha256=${expected}`;
12}
13
14// Usage in Express:
15// const sig = req.headers['x-webhook-signature'];
16// const ts = req.headers['x-webhook-timestamp'];
17// verifyWebhook(req.body, sig, ts, WEBHOOK_SECRET);

Verification example (Python)

1import hmac
2import hashlib
3import time
4
5def verify_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
6 # Reject requests older than 5 minutes to prevent replay attacks
7 if abs(time.time() - int(timestamp)) > 300:
8 return False
9 signed_string = f"{timestamp}.".encode() + raw_body
10 expected = hmac.new(
11 secret.encode(), signed_string, hashlib.sha256
12 ).hexdigest()
13 return hmac.compare_digest(f"sha256={expected}", signature)
14
15# Usage in Flask/FastAPI:
16# signature = request.headers["X-Webhook-Signature"]
17# timestamp = request.headers["X-Webhook-Timestamp"]
18# 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:

AttemptDelayDescription
1ImmediateInitial delivery attempt
25 minutesFirst retry
330 minutesSecond retry
42 hoursThird retry
56 hoursFourth retry
612 hoursFinal 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:

1processed_webhooks = set() # in production, use Redis or a database
2
3@app.route('/webhook', methods=['POST'])
4def webhook():
5 webhook_id = request.headers.get('X-Webhook-ID')
6 if webhook_id in processed_webhooks:
7 return 'OK', 200 # already processed
8
9 # ... process the webhook ...
10
11 processed_webhooks.add(webhook_id)
12 return 'OK', 200
1const processed = new Set(); // in production, use Redis or a database
2
3app.post('/webhook', (req, res) => {
4 const webhookId = req.headers['x-webhook-id'];
5 if (processed.has(webhookId)) return res.status(200).send('OK');
6
7 // ... process the webhook ...
8
9 processed.add(webhookId);
10 res.status(200).send('OK');
11});

Conversation state

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

$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

FieldTypeRequiredDescription
urlstringYesThe HTTPS URL to receive webhook deliveries
contextLimitinteger or nullNoNumber of recent messages to include in webhook payloads (0-50, default: 10). Set to 0 to disable history.
timeoutinteger or nullNoMax 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

$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}'
1{
2 "id": "wh_mno678",
3 "url": "https://your-server.com/webhook",
4 "secret": "whsec_abc123...",
5 "status": "active",
6 "contextLimit": 10,
7 "timeout": 30,
8 "createdAt": "2025-01-15T11:00:00Z"
9}

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

$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

ParameterTypeRequiredDefaultDescription
limitintegerNo50Number of results to return (max 100)
offsetintegerNo0Number of results to skip (min 0)
hoursintegerNonullOptional lookback window in hours (1-168)

Example

$curl -X GET "https://api.agentphone.ai/v1/webhooks/deliveries?limit=10&offset=0" \
> -H "Authorization: Bearer YOUR_API_KEY"
1{
2 "items": [
3 {
4 "id": "del_pqr901",
5 "messageId": "msg_001",
6 "eventType": "agent.message",
7 "status": "success",
8 "httpStatus": 200,
9 "errorMessage": null,
10 "attemptCount": 1,
11 "lastAttemptAt": "2025-01-15T12:00:01Z",
12 "nextRetryAt": null,
13 "createdAt": "2025-01-15T12:00:01Z"
14 }
15 ],
16 "total": 124,
17 "offset": 0,
18 "limit": 10
19}

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

$curl -X POST "https://api.agentphone.ai/v1/webhooks/test" \
> -H "Authorization: Bearer YOUR_API_KEY"
1{
2 "success": true,
3 "httpStatus": 200,
4 "errorMessage": null
5}

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
FieldTypeRequiredDescription
urlstringYesThe HTTPS URL to receive webhook deliveries
contextLimitinteger or nullNoNumber of recent messages to include in webhook payloads (0-50, default: 10). Set to 0 to disable history.
timeoutinteger or nullNoMax 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.

$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}'
1{
2 "id": "wh_xyz789",
3 "url": "https://your-server.com/agent-webhook",
4 "secret": "whsec_def456...",
5 "status": "active",
6 "contextLimit": 5,
7 "timeout": 30,
8 "createdAt": "2025-01-15T11:00:00Z"
9}

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
$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
$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
ParameterTypeRequiredDefaultDescription
limitintegerNo50Number of results to return (max 100)
$curl -X GET "https://api.agentphone.ai/v1/agents/agt_abc123/webhook/deliveries?limit=10" \
> -H "Authorization: Bearer YOUR_API_KEY"
1[
2 {
3 "id": "del_abc123",
4 "messageId": "msg_001",
5 "eventType": "agent.message",
6 "status": "success",
7 "httpStatus": 200,
8 "errorMessage": null,
9 "attemptCount": 1,
10 "lastAttemptAt": "2025-01-15T12:00:01Z",
11 "nextRetryAt": null,
12 "createdAt": "2025-01-15T12:00:01Z"
13 }
14]

Test an agent webhook

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

POST /v1/agents/{agent_id}/webhook/test
$curl -X POST "https://api.agentphone.ai/v1/agents/agt_abc123/webhook/test" \
> -H "Authorization: Bearer YOUR_API_KEY"
1{
2 "success": true,
3 "httpStatus": 200,
4 "errorMessage": null
5}