Environment
twilio (twilio-python): 9.10.9
- Python: 3.11.15
aiohttp: 3.13.5
Description
The async HTTP client (AsyncTwilioHttpClient) is missing the Content-Type: application/json branch that the sync client has, so for any endpoint that sends a JSON request body it passes the body dict to aiohttp's data= (which form-encodes it) while still sending Content-Type: application/json. Twilio receives a body that isn't valid JSON and rejects it with 400 "Request input may be invalid".
This breaks every newer v2 JSON-body endpoint on the async client. Concrete case: client.messaging.v2.channels_senders(sid).update_async(...) (update a WhatsApp sender's business profile). The identical call on the sync client succeeds.
Root cause
Sync client serializes JSON correctly — twilio/http/http_client.py:
if headers and headers.get("Content-Type") == "application/json":
kwargs["json"] = data # real JSON body
elif headers and headers.get("Content-Type") == "application/scim+json":
kwargs["json"] = data
else:
kwargs["data"] = data
Async client has no such branch — twilio/http/async_http_client.py (request):
kwargs = {
"method": method.upper(),
"url": url,
"params": params,
"data": data, # always form-encoded, even for application/json
"headers": headers,
...
}
...
response = await session.request(**kwargs)
The generated code (e.g. messaging/v2/channels_sender.py::_update_async) correctly sets headers["Content-Type"] = "application/json" and passes data = <dict>, relying on the transport to JSON-encode — which the async client never does.
Reproduction
import asyncio
from twilio.rest import Client
from twilio.http.async_http_client import AsyncTwilioHttpClient
from twilio.rest.messaging.v2.channels_sender import ChannelsSenderList as CSL
async def main():
client = Client(SID, TOKEN, http_client=AsyncTwilioHttpClient())
body = CSL.MessagingV2ChannelsSenderRequestsUpdate(
{"profile": CSL.MessagingV2ChannelsSenderProfile({"logo_url": "https://example.com/x.jpg"})}
)
await client.messaging.v2.channels_senders("XE...").update_async(body) # -> 400
asyncio.run(main())
Result: TwilioRestException(400, '/Channels/Senders/XE...', 'Unable to update record: Request input may be invalid...').
Inspecting the captured request (http_client._test_only_last_request): data is a dict ({'profile': {'logo_url': '...'}}), not a JSON string, with Content-Type: application/json. The same update(body) on the sync TwilioHttpClient returns 200.
Expected
The async client should mirror the sync client: when Content-Type is application/json (or application/scim+json), send the body via aiohttp's json= rather than data=.
Suggested fix
In AsyncTwilioHttpClient.request, replace the unconditional "data": data with the same content-type branch the sync client uses:
if headers and headers.get("Content-Type") in ("application/json", "application/scim+json"):
kwargs["json"] = data
else:
kwargs["data"] = data
Impact
Any async call to a JSON-body endpoint fails (Channels Senders create/update, and other v2 resources that send a JSON schema body). Form-encoded endpoints (Messages, Calls, Lookups, etc.) are unaffected. Current workaround: send the request directly (e.g. httpx/aiohttp with json=), which is what the sync client does internally.
Environment
twilio(twilio-python): 9.10.9aiohttp: 3.13.5Description
The async HTTP client (
AsyncTwilioHttpClient) is missing theContent-Type: application/jsonbranch that the sync client has, so for any endpoint that sends a JSON request body it passes the body dict to aiohttp'sdata=(which form-encodes it) while still sendingContent-Type: application/json. Twilio receives a body that isn't valid JSON and rejects it with400 "Request input may be invalid".This breaks every newer v2 JSON-body endpoint on the async client. Concrete case:
client.messaging.v2.channels_senders(sid).update_async(...)(update a WhatsApp sender's business profile). The identical call on the sync client succeeds.Root cause
Sync client serializes JSON correctly —
twilio/http/http_client.py:Async client has no such branch —
twilio/http/async_http_client.py(request):The generated code (e.g.
messaging/v2/channels_sender.py::_update_async) correctly setsheaders["Content-Type"] = "application/json"and passesdata = <dict>, relying on the transport to JSON-encode — which the async client never does.Reproduction
Result:
TwilioRestException(400, '/Channels/Senders/XE...', 'Unable to update record: Request input may be invalid...').Inspecting the captured request (
http_client._test_only_last_request):datais a dict ({'profile': {'logo_url': '...'}}), not a JSON string, withContent-Type: application/json. The sameupdate(body)on the syncTwilioHttpClientreturns 200.Expected
The async client should mirror the sync client: when
Content-Typeisapplication/json(orapplication/scim+json), send the body via aiohttp'sjson=rather thandata=.Suggested fix
In
AsyncTwilioHttpClient.request, replace the unconditional"data": datawith the same content-type branch the sync client uses:Impact
Any async call to a JSON-body endpoint fails (Channels Senders create/update, and other v2 resources that send a JSON schema body). Form-encoded endpoints (Messages, Calls, Lookups, etc.) are unaffected. Current workaround: send the request directly (e.g.
httpx/aiohttpwithjson=), which is what the sync client does internally.