Overview

Wirexa API is a self-hosted telephony platform compatible with the SignalWire and Twilio REST API and XML (LaML) verb format. You can use existing SignalWire or Twilio SDKs without modification — just point them at your engine's base URL.

Base URL: http://wirexaapi.com

API prefix: /api/laml/2010-04-01/Accounts/{ProjectID}/

This is a private, self-hosted engine. Credentials are issued by your platform administrator via the dashboard.

Authentication

All API requests use HTTP Basic Auth. Your Project ID is the username and your Auth Token is the password.

Shell
curl https://your-engine.com/api/laml/2010-04-01/Accounts/{ProjectID}/Calls.json \
  -u "{ProjectID}:{AuthToken}"

In Python SDKs, pass these as project_id and auth_token (SignalWire) or account_sid and auth_token (Twilio).

SDK

Install the official Wirexa API SDK with pip — no files to copy, no manual setup.

Shell
pip install wirexaapi

If you were using signalwire_replacement.py or the official SignalWire SDK, just change the import — nothing else changes:

Python
# Before (SignalWire official SDK or signalwire_replacement.py)
from signalwire.rest import Client as SignalWireClient

# After — rest of your code stays the same
from wirexaapi import Client as SignalWireClient

Quick Start

With settings.json (recommended)

JSON — settings.json
{
  "signalwire": {
    "project_id": "your_project_id",
    "api_token": "PTxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "space_url": "wirexaapi.com"
  }
}
Python
import json
from wirexaapi import Client as SignalWireClient

with open("settings.json") as f:
    jsonsettings = json.load(f)

sw = jsonsettings["signalwire"]

signalwire_client = SignalWireClient(
    sw["project_id"],
    sw["api_token"],
    signalwire_space_url=sw["space_url"],
)

call = signalwire_client.calls.create(
    to="+13125551234",
    from_="+18005550100",
    url="https://your-backend.com/xml",
    status_callback="https://your-backend.com/status",
    status_callback_event=["initiated", "ringing", "completed"],
    recording_status_callback="https://your-backend.com/recording-status",
    machine_detection="Enable",
    record=True,
)
print(call.sid, call.status)  # CAxxxxxxxx  queued

Minimal XML backend (Python/Flask)

Python
from flask import Flask, request, Response
app = Flask(__name__)

@app.route("/xml", methods=["POST"])
def xml_response():
    xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Hello! This call is being recorded.</Say>
  <Gather numDigits="1" timeout="5" action="/gather">
    <Say>Press 1 for sales, 2 for support.</Say>
  </Gather>
  <Hangup/>
</Response>"""
    return Response(xml, mimetype="text/xml")

@app.route("/gather", methods=["POST"])
def gather():
    digit = request.form.get("Digits", "")
    xml = f"<Response><Say>You pressed {digit}.</Say><Hangup/></Response>"
    return Response(xml, mimetype="text/xml")

@app.route("/status", methods=["POST"])
def status():
    print(request.form)  # CallSid, CallStatus, AnsweredBy, etc.
    return "OK"

Create a Call

POST/api/laml/2010-04-01/Accounts/{ProjectID}/Calls.json

Shell
curl -X POST "http://wirexaapi.com/api/laml/2010-04-01/Accounts/{ProjectID}/Calls.json" \
  -u "{ProjectID}:{AuthToken}" \
  --data-urlencode "To=+13125551234" \
  --data-urlencode "From=+18005550100" \
  --data-urlencode "Url=https://your-backend.com/xml" \
  --data-urlencode "StatusCallback=https://your-backend.com/status" \
  --data-urlencode "MachineDetection=Enable" \
  --data-urlencode "Record=true" \
  --data-urlencode "RecordingStatusCallback=https://your-backend.com/recording-status"

Response

JSON
{
  "sid": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "account_sid": "your_project_id",
  "to": "+13125551234",
  "from": "+18005550100",
  "status": "queued",
  "direction": "outbound-api",
  "url": "https://your-backend.com/xml"
}

Call Parameters

ParameterTypeRequiredDescription
TostringYesDestination number, E.164 format (e.g. +13125551234)
FromstringYesCaller ID. Must be configured on your SIP trunk.
UrlstringYesURL that returns LaML/XML instructions for the call flow.
MethodstringNoHTTP method to fetch Url. Default: POST
StatusCallbackstringNoURL to receive call lifecycle events.
StatusCallbackMethodstringNoHTTP method for StatusCallback. Default: POST
StatusCallbackEventstring[]NoSubset of events: initiated, ringing, answered, completed. Default: all.
MachineDetectionstringNoEnable to activate AMD. Result in AnsweredBy webhook field.
RecordbooleanNotrue to record the full call as WAV + MP3.
RecordingStatusCallbackstringNoURL to receive recording completion webhook.
RecordingStatusCallbackMethodstringNoHTTP method for recording callback. Default: POST
TimeoutintegerNoSeconds to wait for answer before marking as no-answer. Default: 60.

List & Inspect Calls

GET/api/laml/2010-04-01/Accounts/{ProjectID}/Calls.json

Shell
# List all calls (most recent first)
curl "http://wirexaapi.com/api/laml/2010-04-01/Accounts/{ProjectID}/Calls.json" \
  -u "{ProjectID}:{AuthToken}"

# Filter by status
curl "http://wirexaapi.com/api/laml/2010-04-01/Accounts/{ProjectID}/Calls.json?Status=completed&PageSize=50" \
  -u "{ProjectID}:{AuthToken}"

# Get a specific call
curl "http://wirexaapi.com/api/laml/2010-04-01/Accounts/{ProjectID}/Calls/{CallSid}.json" \
  -u "{ProjectID}:{AuthToken}"

Query parameters

ParameterDescription
StatusFilter: queued | ringing | in-progress | completed | failed | busy | no-answer
PageSizeNumber of results (default 50, max 1000)
PagePage number (0-indexed)

Modify a Call

POST/api/laml/2010-04-01/Accounts/{ProjectID}/Calls/{CallSid}.json

Modify an in-progress call. Currently supported: hangup.

Shell
# Hang up a call
curl -X POST "http://wirexaapi.com/api/laml/2010-04-01/Accounts/{ProjectID}/Calls/{CallSid}.json" \
  -u "{ProjectID}:{AuthToken}" \
  --data-urlencode "Status=completed"

<Say>

Converts text to speech and plays it to the caller. Supports multiple TTS providers via the voice attribute.

XML
<Response>
  <Say language="en-US" voice="polly.Joanna" loop="1">
    Hello! Thank you for calling.
  </Say>
</Response>
AttributeDefaultDescription
languageen-USBCP-47 language tag. Passed to the TTS provider.
voicewomanVoice identifier. Format: provider.VoiceId. See TTS Providers.
loop1Number of times to repeat. 0 = infinite (until DTMF or hangup).

<Play>

Plays an audio file (WAV or MP3) to the caller.

XML
<Response>
  <Play loop="1">https://example.com/greeting.wav</Play>
</Response>
AttributeDefaultDescription
loop1Number of times to repeat. 0 = infinite.

<Gather>

Collects DTMF digits from the caller. Nest <Say> or <Play> inside to play a prompt — digits pressed during the prompt are captured immediately.

XML
<Response>
  <Gather numDigits="1" timeout="7"
          action="https://your-backend.com/gather" method="POST">
    <Say>Press 1 for sales, 2 for support, or 3 to repeat.</Say>
  </Gather>
  <!-- Fallback if no digits collected: -->
  <Say>We did not receive your input. Goodbye.</Say>
  <Hangup/>
</Response>
AttributeDefaultDescription
numDigitsStop collecting after exactly N digits. Omit for open-ended collection.
timeout5Seconds to wait for the first digit after the prompt ends.
finishOnKey#Key that ends collection immediately (not included in Digits).
actioncurrent URLURL to POST collected digits to. If omitted, re-POSTs to the current URL.
methodPOSTHTTP method for the action URL.

Action request fields

FieldDescription
DigitsCollected digit string, e.g. 1 or 1234
CallSidUnique call identifier
FromCaller number
ToDestination number
AccountSidYour Project ID

<Record>

Records the caller's audio. The recording is saved as WAV and converted to MP3.

XML
<Response>
  <Say>Please leave your message after the tone.</Say>
  <Record maxLength="60" silence="5"
          action="https://your-backend.com/after-record"
          method="POST"/>
</Response>
AttributeDefaultDescription
maxLength3600Maximum recording length in seconds.
silence5Stop recording after N seconds of silence.
actionURL called when recording is complete. Receives RecordingUrl, RecordingDuration.
methodPOSTHTTP method for action URL.
playBeeptruePlay a beep before recording starts.

<Dial>

Bridges the call to another number.

XML
<Response>
  <Dial timeout="30" callerId="+18005550100">
    +13125551234
  </Dial>
</Response>
AttributeDefaultDescription
timeout30Seconds to wait for the dialed party to answer.
callerIdoriginal FromCaller ID shown to the dialed party.
actionURL to call when the dialed party hangs up.

<Redirect> / <Pause> / <Hangup>

XML
<Response>
  <!-- Pause for 2 seconds -->
  <Pause length="2"/>

  <!-- Redirect to a new URL (replaces remaining verbs) -->
  <Redirect method="GET">https://your-backend.com/next-step</Redirect>

  <!-- Hang up the call -->
  <Hangup/>
</Response>
VerbAttributeDescription
<Pause>length (default: 1)Pause execution for N seconds. Caller hears silence.
<Redirect>method (default: POST)Fetch new XML from the given URL and continue execution from there.
<Hangup>Immediately hang up the call.

TTS Providers

Set the voice attribute on <Say> using the format provider.VoiceId. If the specified provider is unavailable, the engine falls back automatically: Polly → Google → ElevenLabs → Azure → espeak (system).

voice valueProviderNotes
polly.JoannaAmazon PollyEnglish (US), female
polly.MiguelAmazon PollySpanish (ES), male
google.en-US-Wavenet-FGoogle Cloud TTSEnglish (US), female, WaveNet quality
eleven.RachelElevenLabsHigh-quality neural voice
azure.en-US-JennyNeuralAzure Cognitive ServicesEnglish (US), neural
womanAuto (best available)Tries each provider in order
manAuto (best available)Tries each provider in order
For production, configure at least one cloud TTS provider. espeak is the last-resort fallback and produces robotic quality audio.

Answering Machine Detection (AMD)

Enable AMD by passing MachineDetection=Enable when creating the call. The engine captures the first ~4 seconds of audio and classifies it using WebRTC VAD.

Result values

AnsweredByMeaningConfidence threshold
humanShort speech bursts, pauses, or initial silence before speaking — human pattern≥ 0.65
machineLong continuous speech (>2.4 s) from call start — voicemail greeting pattern≥ 0.70
unknownNo audio, or ambiguous pattern within 4 s analysis window

The result is delivered in the AnsweredBy field of the answered status callback.

Recordings

Enable recording by passing Record=true when creating the call, or using the <Record> verb in your XML. Recordings are saved as WAV on disk and converted to MP3 via ffmpeg.

Download recordings

Shell
# By recording SID
curl "http://wirexaapi.com/recordings/{RecordingSid}.mp3" -o call.mp3
curl "http://wirexaapi.com/recordings/{RecordingSid}.wav" -o call.wav

# By call SID (first completed recording)
curl "http://wirexaapi.com/recordings/call/{CallSid}.mp3" -o call.mp3

# Recording metadata (JSON)
curl "http://wirexaapi.com/2010-04-01/Accounts/{ProjectID}/Recordings/{RecordingSid}.json"
Recording endpoints are public (no auth required). Use signed URL schemes or network-level access controls if you need to restrict access.

Recording status webhook fields

FieldExampleDescription
RecordingSidRExxxxxUnique recording identifier
CallSidCAxxxxxParent call SID
RecordingStatuscompletedprocessing | completed | failed
RecordingUrlhttp://.../recordings/RExxxxxBase URL. Append .mp3 or .wav to download.
RecordingDuration45Duration in seconds
RecordingChannelsmonomono or stereo
AccountSidyour_project_idYour Project ID

Webhooks

Status callbacks are sent as HTTP POST with application/x-www-form-urlencoded body. Failed deliveries are retried with exponential backoff and persist across engine restarts.

Retry schedule

AttemptDelay
1st (immediate)0 s
2nd60 s
3rd5 min
4th15 min
After 4 failuresMarked as failed — no more retries

Call status webhook fields

FieldExampleDescription
CallSidCAxxxxxUnique call identifier
CallStatuscompletedinitiated | ringing | answered | completed | failed | busy | no-answer
To+13125551234Destination number
From+18005550100Caller ID
Directionoutbound-apiCall direction
CallDuration45Duration in seconds (included on completed)
AnsweredByhumanAMD result: human | machine | unknown (only when AMD enabled)
AccountSidyour_project_idYour Project ID
Timestamp2026-03-06T12:00:00ZISO 8601 event time
Two separate callbacks are fired at call end: (1) recording callback → RecordingStatusCallback (with RecordingUrl, RecordingSid, etc.) and (2) call status callback → StatusCallback (with CallStatus, AnsweredBy, etc.). They do not overlap.

Error Codes

HTTP StatusMeaning
200Success
201Resource created
202Accepted — resource still processing (e.g. recording converting to MP3)
400Bad request — missing or invalid parameters
401Unauthorized — invalid or missing credentials
402Payment required — account has no credits
404Resource not found
429Rate limit exceeded
500Internal server error
503Service temporarily unavailable

Error response format

JSON
{
  "detail": "No credits remaining for this project"
}

IVR Backend — Overview

Your application acts as a webhook server. The Voice Engine calls your endpoints to get XML instructions and to deliver event notifications. Your app also calls the Engine's REST API to create outbound calls.

Flow:
1. Your backend → POST /Calls.json → Engine creates the call
2. Engine → POST /xml → Your backend returns XML instructions
3. Engine executes XML (Say, Gather, Record…)
4. Engine → POST /gather → Your backend receives digits, returns new XML
5. Engine → POST /amd → Your backend receives AMD result (if enabled)
6. Engine → POST /status → Your backend receives final call status
During local development, use ngrok to expose your backend:
ngrok http 9000 → copy the HTTPS URL as MY_PUBLIC_URL

Setup & Config

Install dependencies

Shell
pip install fastapi uvicorn httpx python-multipart

Environment variables (.env)

.env
# The Voice Engine base URL
ENGINE_URL=https://wirexaapi.com

# Your credentials — copy from the Dashboard
ACCOUNT_SID=your_project_id
AUTH_TOKEN=your_api_token

# Public URL of YOUR backend (Engine needs this to reach your webhooks)
# Use ngrok during local development: ngrok http 9000
MY_PUBLIC_URL=https://your-backend.example.com

Run

Shell
uvicorn app:app --port 9000 --reload

1. Create a Call

Call the Engine's REST API to start an outbound call. The Engine will call your /xml endpoint once the call is answered to get instructions.

Python
import os, httpx
from fastapi import FastAPI

app = FastAPI()

ENGINE_URL    = os.getenv("ENGINE_URL",   "https://wirexaapi.com")
ACCOUNT_SID   = os.getenv("ACCOUNT_SID", "your_project_id")
AUTH_TOKEN    = os.getenv("AUTH_TOKEN",  "your_api_token")
MY_PUBLIC_URL = os.getenv("MY_PUBLIC_URL","https://your-backend.example.com")


@app.post("/call")
async def create_call(to: str, from_number: str = "+10000000000"):
    async with httpx.AsyncClient(timeout=15) as client:
        r = await client.post(
            f"{ENGINE_URL}/api/laml/2010-04-01/Accounts/{ACCOUNT_SID}/Calls.json",
            auth=(ACCOUNT_SID, AUTH_TOKEN),
            data={
                "To":                     to,
                "From":                   from_number,
                "Url":                    f"{MY_PUBLIC_URL}/xml",          # (2)
                "Method":                 "POST",
                "StatusCallback":         f"{MY_PUBLIC_URL}/status",       # (6)
                "StatusCallbackMethod":   "POST",
                # Optional: answering machine detection
                "MachineDetection":       "Enable",
                "AsyncAmd":               "true",
                "AsyncAmdStatusCallback": f"{MY_PUBLIC_URL}/amd",          # (5)
            },
        )
    data = r.json()
    return {"call_sid": data.get("sid"), "status": data.get("status")}

2. Serve XML Instructions

When the call is answered the Engine sends a POST to your /xml URL. Respond with valid LaML/XML to control the call.

Fields sent by the Engine: CallSid, CallStatus, To, From, Direction, AnsweredBy.

Python
from fastapi import Request
from fastapi.responses import PlainTextResponse

@app.post("/xml", response_class=PlainTextResponse)
async def xml_instructions(request: Request) -> str:
    form = dict(await request.form())
    call_sid    = form.get("CallSid", "")
    answered_by = form.get("AnsweredBy", "")

    # Route differently if a machine answered
    if answered_by == "machine_start":
        return """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Please call us back at our office. Thank you.</Say>
  <Hangup/>
</Response>"""

    # Human answered — play IVR menu
    return f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Hello, thank you for answering.</Say>
  <Gather numDigits="1" timeout="7"
          action="{MY_PUBLIC_URL}/gather" method="POST">
    <Say>
      Press 1 for sales.
      Press 2 for support.
      Press 9 to be removed from our list.
    </Say>
  </Gather>
  <Say>We did not receive your selection. Goodbye.</Say>
  <Hangup/>
</Response>"""

3. Handle Gather (Digits)

After the caller presses a key, the Engine sends a POST to your /gather URL. Respond with XML to continue the call.

Fields: CallSid, Digits, From, To.

Python
@app.post("/gather", response_class=PlainTextResponse)
async def gather(request: Request) -> str:
    form   = dict(await request.form())
    digits = form.get("Digits", "")

    if digits == "1":
        return """<Response>
  <Say>Connecting you to sales. Please hold.</Say>
  <Transfer timeout="30">+13125550100</Transfer>
</Response>"""

    elif digits == "2":
        return """<Response>
  <Say>Our support team will email you shortly. Goodbye.</Say>
  <Hangup/>
</Response>"""

    elif digits == "9":
        # TODO: mark number as do-not-call in your database
        return """<Response>
  <Say>You have been removed from our list. Goodbye.</Say>
  <Hangup/>
</Response>"""

    else:
        # Unrecognised digit — repeat menu
        return f"""<Response>
  <Gather numDigits="1" timeout="7"
          action="{MY_PUBLIC_URL}/gather" method="POST">
    <Say>Invalid option. Press 1 for sales, 2 for support.</Say>
  </Gather>
  <Hangup/>
</Response>"""

4. AMD Result

When MachineDetection=Enable and AsyncAmd=true are set, the Engine sends AMD results here asynchronously — typically within 2–5 seconds of the call being answered.

Fields: CallSid, AnsweredBy (human | machine_start | unknown), AmdConfidence, AmdDetectionTimeMs.

Python
@app.post("/amd")
async def amd_result(request: Request) -> dict:
    form        = dict(await request.form())
    call_sid    = form.get("CallSid", "")
    answered_by = form.get("AnsweredBy", "")  # human | machine_start | unknown
    confidence  = form.get("AmdConfidence", "")
    time_ms     = form.get("AmdDetectionTimeMs", "")

    print(f"[{call_sid}] AMD: {answered_by} (confidence={confidence}, {time_ms}ms)")

    if answered_by == "machine_start":
        # Optionally redirect the call to leave a voicemail
        async with httpx.AsyncClient(timeout=10) as client:
            await client.post(
                f"{ENGINE_URL}/api/laml/2010-04-01/Accounts/{ACCOUNT_SID}/Calls/{call_sid}.json",
                auth=(ACCOUNT_SID, AUTH_TOKEN),
                data={"Url": f"{MY_PUBLIC_URL}/xml/voicemail", "Method": "POST"},
            )

    return {"received": True}

5. Status Callback

Fires at call end (and optionally at answered, initiated, ringing). Use this to update your database, trigger billing, etc.

Fields: CallSid, CallStatus, To, From, Direction, CallDuration, AnsweredBy.

Python
@app.post("/status")
async def status_callback(request: Request) -> dict:
    form     = dict(await request.form())
    sid      = form.get("CallSid", "")
    status   = form.get("CallStatus", "")
    duration = form.get("CallDuration", "0")

    print(f"[{sid}] status={status} duration={duration}s")

    if status == "completed":
        pass  # update DB, send report, etc.
    elif status == "failed":
        pass  # retry logic, alert, etc.
    elif status == "no-answer":
        pass  # reschedule, etc.

    return {"received": True}
A separate recording callback fires to RecordingStatusCallback with RecordingUrl, RecordingSid, and RecordingDuration. It does not include CallStatus — keep them separate in your logic.

Full Example

Complete app.py — ready to run. Copy, set your .env, and start with uvicorn app:app --port 9000 --reload.

Python — app.py
"""
IVR backend example — connects to the Wirexa Voice Engine.

Install:  pip install fastapi uvicorn httpx python-multipart
Run:      uvicorn app:app --port 9000 --reload
"""
from __future__ import annotations
import logging, os
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)

app = FastAPI()

ENGINE_URL    = os.getenv("ENGINE_URL",    "https://wirexaapi.com")
ACCOUNT_SID   = os.getenv("ACCOUNT_SID",  "your_project_id")
AUTH_TOKEN    = os.getenv("AUTH_TOKEN",    "your_api_token")
MY_PUBLIC_URL = os.getenv("MY_PUBLIC_URL", "https://your-backend.example.com")

AUTH = (ACCOUNT_SID, AUTH_TOKEN)
CALLS_URL = f"{ENGINE_URL}/api/laml/2010-04-01/Accounts/{ACCOUNT_SID}/Calls.json"


# ── 1. CREATE A CALL ──────────────────────────────────────────────────────────

@app.post("/call")
async def create_call(to: str, from_number: str = "+10000000000") -> dict:
    async with httpx.AsyncClient(timeout=15) as client:
        r = await client.post(CALLS_URL, auth=AUTH, data={
            "To":                     to,
            "From":                   from_number,
            "Url":                    f"{MY_PUBLIC_URL}/xml",
            "Method":                 "POST",
            "StatusCallback":         f"{MY_PUBLIC_URL}/status",
            "StatusCallbackMethod":   "POST",
            "MachineDetection":       "Enable",
            "AsyncAmd":               "true",
            "AsyncAmdStatusCallback": f"{MY_PUBLIC_URL}/amd",
        })
    data = r.json()
    log.info("Call created: %s → %s", data.get("sid"), to)
    return {"call_sid": data.get("sid"), "status": data.get("status")}


# ── 2. SERVE XML ──────────────────────────────────────────────────────────────

@app.post("/xml", response_class=PlainTextResponse)
async def xml_instructions(request: Request) -> str:
    form = dict(await request.form())
    log.info("[%s] XML requested — answered_by=%s", form.get("CallSid"), form.get("AnsweredBy"))

    return f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Hello, thank you for answering.</Say>
  <Gather numDigits="1" timeout="7"
          action="{MY_PUBLIC_URL}/gather" method="POST">
    <Say>
      Press 1 for sales.
      Press 2 for support.
      Press 9 to be removed from our list.
    </Say>
  </Gather>
  <Say>We did not receive your selection. Goodbye.</Say>
  <Hangup/>
</Response>"""


# ── 3. HANDLE GATHER ─────────────────────────────────────────────────────────

@app.post("/gather", response_class=PlainTextResponse)
async def gather(request: Request) -> str:
    form   = dict(await request.form())
    digits = form.get("Digits", "")
    log.info("[%s] Gather: digits=%r", form.get("CallSid"), digits)

    if digits == "1":
        return """<Response>
  <Say>Connecting you to sales.</Say>
  <Transfer timeout="30">+13125550100</Transfer>
</Response>"""
    elif digits == "2":
        return """<Response>
  <Say>Our team will contact you. Goodbye.</Say>
  <Hangup/>
</Response>"""
    elif digits == "9":
        return """<Response>
  <Say>You have been removed from our list. Goodbye.</Say>
  <Hangup/>
</Response>"""
    else:
        return f"""<Response>
  <Gather numDigits="1" timeout="7"
          action="{MY_PUBLIC_URL}/gather" method="POST">
    <Say>Invalid option. Press 1 for sales, 2 for support.</Say>
  </Gather>
  <Hangup/>
</Response>"""


# ── 4. AMD RESULT ─────────────────────────────────────────────────────────────

@app.post("/amd")
async def amd_result(request: Request) -> dict:
    form        = dict(await request.form())
    call_sid    = form.get("CallSid", "")
    answered_by = form.get("AnsweredBy", "")
    log.info("[%s] AMD: %s (%sms)", call_sid, answered_by, form.get("AmdDetectionTimeMs"))
    # Redirect to voicemail XML if machine answered
    if answered_by == "machine_start":
        async with httpx.AsyncClient(timeout=10) as client:
            await client.post(
                f"{ENGINE_URL}/api/laml/2010-04-01/Accounts/{ACCOUNT_SID}/Calls/{call_sid}.json",
                auth=AUTH,
                data={"Url": f"{MY_PUBLIC_URL}/xml/voicemail", "Method": "POST"},
            )
    return {"received": True}


# ── 5. STATUS CALLBACK ────────────────────────────────────────────────────────

@app.post("/status")
async def status_callback(request: Request) -> dict:
    form   = dict(await request.form())
    sid    = form.get("CallSid", "")
    status = form.get("CallStatus", "")
    log.info("[%s] status=%s duration=%ss", sid, status, form.get("CallDuration", "0"))
    # TODO: update your database here
    return {"received": True}


# ── HEALTH ────────────────────────────────────────────────────────────────────

@app.get("/health")
async def health() -> dict:
    try:
        async with httpx.AsyncClient(timeout=5) as c:
            r = await c.get(f"{ENGINE_URL}/health")
            engine_ok = r.status_code == 200
    except Exception:
        engine_ok = False
    return {"backend": "ok", "engine_connected": engine_ok}