Skip to content

AsyncTwilioHttpClient does not JSON-serialize request bodies for application/json endpoints (sync client does) → 400 on JSON-body v2 endpoints #932

Description

@adcore-dev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions