What this page covers
Local development means running your webhook handlers, tool endpoints, and inbound routing logic on your own machine while real Finn calls hit them. Three things make that work: a public tunnel, replayed events, and a test number you can dial without spending outbound credit.
Expose your local server
Finn calls your endpoint over HTTPS from the public internet. localhost:3000 is not reachable. Run a tunnel and give Finn the tunnel URL.
# ngrok
ngrok http 3000
# → Forwarding https://a1b2c3d4.ngrok-free.app -> http://localhost:3000
# cloudflared
cloudflared tunnel --url http://localhost:3000
Then point the relevant setting at the tunnel URL. Inbound routing URLs are set per phone number:
curl https://api.hirefinn.ai/v1/phone-numbers/ph_1234 \
-X PATCH \
-H "Authorization: Bearer $FINN_API_KEY" \
-d '{ "inbound_webhook_url": "https://a1b2c3d4.ngrok-free.app/finn/inbound" }'
You can also set it in the dashboard at Settings → Phone Numbers → [number] → Inbound Webhook URL. Finn pings the URL once with {"ping": true} to check reachability, so a save that appears to succeed does not mean your handler parsed the body correctly. Watch your own logs.
Post-call webhook endpoints are configured separately. See webhooks.
What goes wrong with tunnels
| Symptom | Cause |
|---|---|
| Calls fall back to the default agent | Tunnel hop plus your handler exceeds the 500ms inbound budget. Timeout is 1000ms. |
| Caller hears 2-3s of silence before the agent speaks | Handler is under the timeout but slow. See webhook-events for the latency budget. |
| Everything worked, then stopped | Free tunnel URLs rotate on restart. The old URL is still saved on the phone number. |
| Signature verification fails locally | You are verifying the parsed body, not the raw bytes. See webhook-security. |
The inbound handshake is on the hot path. The caller is listening to ring tone while your laptop, your tunnel, and your database all take a turn. A handler that is fine in production may miss the budget through a tunnel. Test correctness locally, test latency in a deployed environment.
Replay and simulate events
You do not need a real PSTN ring to exercise inbound routing. The Finn CLI simulates the call:
finn inbound simulate \
--phone-number-id ph_1234 \
--from +919876543210 \
--webhook-url https://a1b2c3d4.ngrok-free.app/finn/inbound
Output shows your endpoint's response, the round-trip latency, and the agent and variables Finn resolved from it. It exits non-zero on an invalid response, so it works in CI as a contract test against your handler.
Point --webhook-url straight at http://localhost:3000 when you want to skip the tunnel and test handler logic alone. That measures your code without the tunnel hop, which is the number worth optimizing.
You can also drive the same handler with cURL, which is useful when you want a payload the CLI does not generate:
curl -X POST http://localhost:3000/finn/inbound \
-H "Content-Type: application/json" \
-d '{
"session_id": "ws_local001",
"request_type": "inbound",
"phone_number_id": "ph_1234",
"channel": "pstn",
"from": { "phone": "+919876543210", "country": "IN", "carrier": "Airtel" },
"to": { "phone": "+918765432100", "country": "IN" },
"received_at": "2026-05-22T14:30:00Z",
"metadata": {}
}'
Signed requests will not verify against a hand-written cURL body unless you compute the HMAC yourself. Gate verification behind an environment flag in development rather than deleting the check, so it cannot ship disabled.
Test calls
Trigger a test call from the dashboard rather than the API. New accounts start with $25 of credit, visible at Settings → Plan and billing → Usage.
Test calls fail for reasons that look like bugs but are not:
| Symptom | Check |
|---|---|
| No call arrives | Phone number needs a country code. +15551234567, +919876543210. |
| No call arrives | Trial credit exhausted. Settings → Plan and billing → Usage. |
| No call arrives | Your number is on the global suppression list. Settings → Compliance → Do Not Call List. |
| No call arrives | Carrier or handset spam filter dropped it silently. Try a different from-number. |
| Call connects, agent is silent | Welcome message set to defined with an empty field. Set text or switch to dynamic. |
| Call connects, agent is silent | Voice not assigned, or assigned voice needs a region match. Re-select it. |
| Wrong agent answered | Your handler returned a finn_id belonging to another org. |
Variables that do not reach the agent are almost always a naming mismatch. The prompt placeholder {customer_name} must match the webhook key exactly. Use snake_case on both sides. See agents-variables.
A workable loop
- Start your server and tunnel.
- Point the phone number at the tunnel URL.
- Iterate with
finn inbound simulateagainst localhost until the response shape is right. - Run the simulator through the tunnel once to confirm reachability and check reported latency.
- Place one real test call from the dashboard.
- Read the transcript and resolved variables in call-logs.
Steps 3 and 5 catch different failures. The simulator never proves audio works, and a test call rarely tells you which line of your handler was wrong.