This post will explain how to use a FastAPI endpoint as the status_callback for twilio’s voice API. I will use FastAPI, Twilio and ngrok to demonstrate the behavior. I start of with this FastAPI file, importing the necessary dependencies and describing a simple endpoint to place calls through Twilio’s API:

 1from fastapi import FastAPI
 2from twilio.rest import Client
 3
 4
 5# twilio variables. get them from your twilio console:
 6# https://www.twilio.com/console
 7account_sid = "YourTwilioAccountSID"
 8auth_token = "YourTwilioAuthToken"
 9
10app = FastAPI()
11
12@app.post("/call_with_twilio")
13async def call_with_twilio(
14    from_number: str,
15    to_number: str,
16    audio_url: str,
17):
18    # init twilio client
19    client = Client(account_sid, auth_token)
20    
21    # send call request to twilio api
22    client.calls.create(
23        url=audio_url,
24        to=to_number,
25        from_=from_number,
26    )
27    return {
28        "message": "Call initiated successfully",
29        "Recipients; to": to_number,
30        "Emitter; from": from_number,
31        "Played Audio Message; url": audio_url,
32    }

So far, so good, as this is pretty much copied directly from Twilio’s documentation: Each request to this endpoint triggers a call via Twilio using the provided from and to phone-numbers and plays the provided audio file from url.

Now, I wanted to see whats going on, that is see each step in the processing of each call. Twilio provides a way to do so via a status_callback parameter passed in the api call. Let’s update our code and add another endpoint call_status to process the feedback for each call:

 1from fastapi import FastAPI
 2from twilio.rest import Client
 3
 4
 5# twilio variables. get them from your twilio console:
 6# https://www.twilio.com/console
 7account_sid = "YourTwilioAccountSID"
 8auth_token = "YourTwilioAuthToken"
 9
10app = FastAPI()
11
12@app.post("/call_with_twilio")
13async def call_with_twilio(
14    from_number: str,
15    to_number: str,
16    audio_url: str,
17):
18    # init twilio client
19    client = Client(account_sid, auth_token)
20    # set url for callbacks on call events
21    status_callback_url = (
22        "https://example.com"
23        + "/call_status"
24    )
25    # send call request to twilio api
26    client.calls.create(
27        method="GET",
28        status_callback=status_callback_url,
29        status_callback_event=[
30            "queued",
31            "initiated",
32            "ringing",
33            "answered",
34            "in-progress",
35            "completed",
36            "busy",
37            "no-answer",
38            "canceled",
39            "failed",
40        ],
41        status_callback_method="POST",
42        url=audio_url,
43        to=to_number,
44        from_=from_number,
45    )
46    return {
47        "message": "Call initiated successfully",
48        "Recipients; to": to_number,
49        "Emitter; from": from_number,
50        "Played Audio Message; url": audio_url,
51    }
52
53@app.post("/call_status")
54async def call_status(request: Request):
55    form_data = await request.form()
56    decoded_data = {}
57
58    for key, value in form_data.items():
59        decoded_data[key] = value
60
61    print(decoded_data)
62    return {"decoded_data": decoded_data}

I added a variable which holds the full url to call_status. If you want to check if this works our, substitute the part before /call_status with a ngrok url. Furthermore I added some parameters to the client.calls.create() function:

  • method="GET" : specifies which method to use to call the Twilio API
  • status_callback=status_callback_url : is the url Twilio will use to continuously send status change events to
  • status_callback_event=["initiated", "ringing", ...] : on which events Twilio should send feedback. If nothing is specified this defaults to “completed”.
  • status_callback_method="POST" : which method will be used to send the feedback to the specified url

Placing a call through endpoint call_with_twilio will now trigger several requests (one for each status change) to https://example.com/call_status. In /call_status I can then further process the received data: store it in a database, send it to another endpoint, store it in the session,… whatever you wish to do with it.

Using requests directly

The important thing to note is that Twilio returns the status data not as json, but as formdata1. Furthermore the form does not always contain the same fields. A status report with "CallStatus": "completed" has for example formfields for "SipResponseCode": "200" and "Duration": "1", which other status don’t have. That’s why I opted to receive the request directly2 and process it from there to a dictionary.

Using FastAPI’s Form()

Another possibility to receive the formdata is via FastAPI’s Form()3:

 1from fastapi import FastAPI, Form
 2from typing import Annotated
 3from twilio.rest import Client
 4
 5
 6# twilio variables. get them from your twilio console:
 7# https://www.twilio.com/console
 8account_sid = "YourTwilioAccountSID"
 9auth_token = "YourTwilioAuthToken"
10
11app = FastAPI()
12
13@app.post("/call_with_twilio")
14async def call_with_twilio(
15    from_number: str,
16    to_number: str,
17    audio_url: str,
18):
19    # init twilio client
20    client = Client(account_sid, auth_token)
21    # set url for callbacks on call events
22    status_callback_url = (
23        "https://example.com"
24        + "/call_status"
25    )
26    # send call request to twilio api
27    client.calls.create(
28        method="GET",
29        status_callback=status_callback_url,
30        status_callback_event=[
31            "queued",
32            "initiated",
33            "ringing",
34            "answered",
35            "in-progress",
36            "completed",
37            "busy",
38            "no-answer",
39            "canceled",
40            "failed",
41        ],
42        status_callback_method="POST",
43        url=audio_url,
44        to=to_number,
45        from_=from_number,
46    )
47    return {
48        "message": "Call initiated successfully",
49        "Recipients; to": to_number,
50        "Emitter; from": from_number,
51        "Played Audio Message; url": audio_url,
52    }
53
54@app.post("/call_status")
55async def call_status(
56    Called: Annotated[str, Form()],
57    ToState: Annotated[str, Form()],
58    CallerCountry: Annotated[str, Form()],
59    Direction: Annotated[str, Form()],
60    Timestamp: Annotated[str, Form()],
61    CallbackSource: Annotated[str, Form()],
62    (...)):
63    
64
65    print(Called)
66    print(ToState)
67    (...)
68    return {}

This is just the basic functionality for receiving the data as FormData. As above, adapt the snippet to do whatever you want with the received data4. This version has the drawback that you have to define the fields beforehand and since they change depending on the status, you will have to adapt to this.


  1. See the respective HEADER: Content-Type: application/x-www-form-urlencoded; charset=UTF-8 ↩︎

  2. FastAPI Documentation for using requests directly: https://fastapi.tiangolo.com/advanced/using-request-directly/ ↩︎

  3. FastAPI Documentation for receiving formdata instead of json: https://fastapi.tiangolo.com/tutorial/request-forms/ ↩︎

  4. A hopefully complete list of fields returned from Twilio ready to use with FastAPI:

    Called: Annotated[str, Form()],
    ToState: Annotated[str, Form()],
    CallerCountry: Annotated[str, Form()],
    Direction: Annotated[str, Form()],
    Timestamp: Annotated[str, Form()],
    CallbackSource: Annotated[str, Form()],
    SipResponseCode: Annotated[str, Form()],
    CallerState: Annotated[str, Form()],
    ToZip: Annotated[str, Form()],
    SequenceNumber: Annotated[str, Form()],
    CallSid: Annotated[str, Form()],
    To: Annotated[str, Form()],
    CallerZip: Annotated[str, Form()],
    ToCountry: Annotated[str, Form()],
    CalledZip: Annotated[str, Form()],
    ApiVersion: Annotated[str, Form()],
    CalledCity: Annotated[str, Form()],
    CallStatus: Annotated[str, Form()],
    Duration: Annotated[str, Form()],
    From: Annotated[str, Form()],
    CallDuration: Annotated[str, Form()],
    AccountSid: Annotated[str, Form()],
    CalledCountry: Annotated[str, Form()],
    CallerCity: Annotated[str, Form()],
    ToCity: Annotated[str, Form()],
    FromCountry: Annotated[str, Form()],
    Caller: Annotated[str, Form()],
    FromCity: Annotated[str, Form()],
    CalledState: Annotated[str, Form()],
    FromZip: Annotated[str, Form()],
    FromState: Annotated[str, Form()]```
    
     ↩︎