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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions