Skip to main content
All schema fields are written snake_case. Charter converts keys to the API’s expected casing at serialization time — request only. Responses are never case-converted, and values are never touched, only keys.

Four levels of granularity

Each level overrides the one before it. First non-None wins.

Per-location parameters

Cases are specified per HTTP location, not one-for-all:
  • body_case — JSON request body keys (default "camel")
  • query_case — URL query string keys (default "snake")
  • path_case — URL path parameter keys (default "snake")
This matters more often than it sounds: Google APIs typically want camelCase bodies but snake_case query parameters.
body_case defaults to "camel", which is right for Google and wrong for most other APIs. Stripe, Slack, OpenAI, Anthropic and anything else documented with snake_case keys want body_case="snake" on the factory. The default is not a guess the runtime can make for you — the same JSON body is valid either way, so nothing local can tell that the spelling is wrong, and it surfaces as the vendor’s own 400 on the first call:
The fix is one argument, and the check is to read a request body off the vendor’s reference page before you write the factory. A pack built against the wrong default fails on its first call rather than silently, which is the intended behaviour — but the second it costs is cheaper than the 400.

Supported cases

Examples

Clean API (most common) — factory level only

camel_api_factory.py

API with snake_case everywhere

snake_api_factory.py

Mixed API — one endpoint differs

per_endpoint_case.py

Schema-level override (nested model)

schema_case.py

Field-level override (single field)

field_case.py

Resolution at serialization time

The cascade is resolved per model, at every depth, not once at the root. A field marker names a field on the schema that declares it, and a __case__ names the keys of the model that declares it — so both are read from whichever model owns the object being serialised. A nested EventDateTime gets its own __case__; the Case("pascal") on one of its fields is honoured there exactly as it would be at the top. What does not inherit is __case__ itself: it says “all keys in that model”, so a model below one that declares it falls back to the endpoint or factory default rather than to its parent’s override. Keys with no model behind them at all — what a Format transform produced, the contents of a free-form Dict[str, Any] — take the endpoint default too, because there is no declaration to read.

One exception: path parameters

A path key is not a key on the wire, it is the name of a hole in the endpoint’s url_template. Only WireName and path_case apply to it. A model’s __case__ does not, since renaming doc_id to DocId would leave {doc_id} with nothing to fill it — a broken template rather than a wire mismatch.

When no convention is right

The cascade converts a whole word at a time, and an acronym inside a field name is where that goes wrong. snake_to_camel capitalises each component, so i_cal_uid becomes iCalUid — and Google Calendar documents iCalUID. The same shape gives htmlUrl for htmlURL and ipAddress for IPAddress. No entry in the cascade produces those, because they are not a convention. WireName names the key outright, and wins over every level above:
Reach for it only where the API’s reference actually spells an acronym in full. Most do not — eventId and fileUrl are camelCase in Google’s own docs — so check rather than guess. This failure is silent, which is why it is worth a marker. Many APIs ignore a query parameter they do not recognise, so the misspelled filter is dropped, the unfiltered result comes back, and the call answers 200. Charter’s own Calendar pack sent iCalUid for as long as it existed. Assert the parameter names on the wire against a respx route; reading the schema is what misses it.