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

# Testing

> Test signed webhook delivery and distinguish synthetic checks from real channel tests.

## Testing webhooks locally

Start with a handler from [Code examples](/docs/documentation/reference/code-examples). Keep signature verification enabled, including during local development.

### Using ngrok

Run your server on port 3000, then expose it through an HTTPS tunnel:

```bash
ngrok http 3000
```

Register the tunnel’s HTTPS URL with `/webhook` appended. Save the returned `secret` as `AGENTPHONE_WEBHOOK_SECRET` and restart the local handler with that value:

```bash
curl --fail-with-body https://api.agentphone.ai/v1/webhooks \
  -H "Authorization: Bearer $AGENTPHONE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://YOUR_TUNNEL_HOST/webhook","contextLimit":10}'
```

The secret is a credential. Keep it out of logs and source control. If the tunnel URL changes, register the new URL and save the secret returned by that update.

### Using localtunnel

A different HTTPS tunnel can be used with the same workflow. Its URL must accept direct server-to-server POST requests without an interstitial, login page, or browser challenge.

## Test webhook endpoint

```bash
curl --fail-with-body -X POST https://api.agentphone.ai/v1/webhooks/test \
  -H "Authorization: Bearer $AGENTPHONE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"channel":"sms"}'
```

This sends a **signed synthetic event**, marked with top-level `test: true`. Supported channels are `sms`, `mms`, `imessage`, `whatsapp`, and `rcs`. A successful response confirms endpoint delivery; it does not prove carrier delivery, registration approval, number readiness, or live voice behavior.

Test a per-agent override with `POST /v1/agents/{agent_id}/webhook/test`. Test in the same [sub-account scope](/docs/sub-accounts) as the webhook. Avoid real outbound sends in response to synthetic events.

## Mock webhook payloads

### SMS payload

Save this fixture as `event.json`:

```json
{
  "event": "agent.message",
  "channel": "sms",
  "test": true,
  "timestamp": "2026-10-26T00:00:00Z",
  "agentId": "agt_example",
  "data": {
    "messageId": "msg_example",
    "numberId": "num_example",
    "from": "+14155550123",
    "to": "+12125550199",
    "message": "Hello from a local test",
    "direction": "inbound"
  }
}
```

### Voice payload

For a local voice fixture, set `channel` to `voice` and put the caller’s current text in `data.transcript`. A voice handler must return `{"text":"..."}` or NDJSON speech chunks, while a text handler just acknowledges. The API’s synthetic test endpoint does **not** test voice.

### Testing with curl

Sign the exact bytes you send. This local script uses the same timestamp/body HMAC as AgentPhone and makes no carrier calls:

**`send_fixture.py`**

```python title="send_fixture.py"
import hashlib
import hmac
import os
from pathlib import Path
import time
import urllib.request
import uuid

delivery_id = os.environ.get("WEBHOOK_DELIVERY_ID") or str(uuid.uuid4())
body = Path("event.json").read_bytes()
timestamp = str(int(time.time()))
secret = os.environ["AGENTPHONE_WEBHOOK_SECRET"].encode()
signature = hmac.new(secret, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
request = urllib.request.Request(
    "http://127.0.0.1:3000/webhook",
    data=body,
    headers={
        "Content-Type": "application/json",
        "X-Webhook-Timestamp": timestamp,
        "X-Webhook-Signature": "sha256=" + signature,
        "X-Webhook-ID": delivery_id,
    },
    method="POST",
)
print("Delivery ID:", delivery_id)
with urllib.request.urlopen(request, timeout=10) as response:
    print(response.status, response.read().decode())
```

Each run generates a new delivery ID. To test a retry, set `WEBHOOK_DELIVERY_ID` to the printed ID and reuse the same payload. For a distinct message event, also change `data.messageId` in `event.json` if your handler deduplicates by message ID.

## Example test handlers

Use the [Express, Flask, or Next.js handlers](/docs/documentation/reference/code-examples). Verify these cases before adding business logic:

1. A valid signature is accepted; a changed body, missing header, or stale timestamp is rejected.
2. A repeated delivery ID does not repeat a queued side effect.
3. A text acknowledgement does not accidentally attempt a voice response or send a paid message.
4. A voice event uses `data.transcript`, including the current turn, and returns speech before the configured timeout.
5. A group reply targets `data.group.groupId`, not the individual sender.

## Checking webhook deliveries

```bash
curl --fail-with-body 'https://api.agentphone.ai/v1/webhooks/deliveries?limit=10' \
  -H "Authorization: Bearer $AGENTPHONE_API_KEY"
```

Inspect `httpStatus`, `errorMessage`, `attemptCount`, and `nextRetryAt`. Master delivery history uses an `items` array; per-agent delivery history returns an array directly. See [Webhooks](/docs/documentation/guides/webhooks#webhook-deliveries).

## Test a real channel

After synthetic checks pass, use a number and recipient you control. Receive an inbound text, verify the webhook, and send one reply with an explicit sender. For voice, use a browser web call or a real phone call and check the transcript and response audio. These tests exercise live service and can incur usage charges. Release temporary numbers separately when finished; deleting their agent does not stop number billing.