OpenAI Chat Completions 400 Bad Request: Causes and Fixes
An OpenAI chat completions 400 bad request means the API rejected your request body. Print the JSON error body to see message, param and code; the usual causes are wrong endpoint for the model, prompt instead of messages, or a bad parameter.
- The status line says only 'Bad Request'; the real cause is in the JSON body fields message, param and code.
- Chat models belong on /v1/chat/completions with a messages array; legacy models like text-davinci-003 belong on /v1/completions and are retired.
- Response text moves from choices[0].text to choices[0].message.content, and stream chunks use choices[0].delta.content.
- Build JSON with a serializer (json.dumps or an SDK), never string concatenation, and send Content-Type: application/json.
- Debug by getting a minimal curl working, then adding one parameter at a time.
How do I see the real error behind an OpenAI chat completions 400 bad request?
A 400 from OpenAI always carries a JSON body shaped like {"error": {"message", "type", "param", "code"}}. Many HTTP clients raise a generic exception such as 'HTTPError: 400 Client Error: Bad Request' and hide it, so print the body first and guess later.
The type is usually invalid_request_error. The param field names the offending field (for example messages or max_tokens), which often solves the problem on its own.
# requests
r = requests.post(url, headers=headers, json=payload)
if not r.ok:
print(r.status_code, r.text) # full error JSON
# openai SDK (v1+)
import openai
try:
client.chat.completions.create(**payload)
except openai.BadRequestError as e:
print(e.status_code, e.body) # dict with message, param, code
print(e.response.text) # raw body
// Node (openai package)
try { await client.chat.completions.create(payload) }
catch (e) { console.log(e.status, e.error, e.message) }
// Node fetch
const res = await fetch(url, opts);
if (!res.ok) console.log(res.status, await res.text());
# curl: -i shows headers and body; drop -f, which hides the body
curl -i https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" -d @body.jsonWhich error message means what? (diagnosis table)
Match the message text to the row. Exact wording varies slightly between API versions, but the key phrases are stable.
| Error text (message) | Cause | Fix |
|---|---|---|
| This is a chat model and not supported in the v1/completions endpoint | Chat model sent to /v1/completions | Call /v1/chat/completions with messages |
| ... is not a chat model / not supported in the v1/chat/completions endpoint | Legacy completion model on the chat endpoint | Use a chat model, or the completions endpoint for instruct models |
| you must provide a messages parameter / 'messages' is a required property | Sent prompt (or nothing) to the chat endpoint | Send messages: [{role, content}] |
| Unrecognized request argument supplied: prompt (or best_of, echo, suffix) | Legacy parameter on chat endpoint | Remove it; move prompt text into messages |
| We could not parse the JSON body of your request | Broken JSON or missing Content-Type | Serialize with json.dumps; set Content-Type: application/json |
| This model's maximum context length is N tokens. However, you requested M | Prompt tokens plus max_tokens exceed window | Trim input, lower max_tokens, or use a larger-context model |
| Unsupported parameter: 'max_tokens' ... Use 'max_completion_tokens' instead | Reasoning model (o-series, newer GPT-5 family) | Rename to max_completion_tokens |
| Unsupported value: 'temperature' does not support 0.2 with this model | Model fixes sampling settings | Omit temperature (and top_p) for that model |
| Invalid type for 'temperature': expected a decimal number, but got a string | Wrong JSON type | Send numbers as numbers, not strings |
| Invalid value for 'content' / expected a string, got null | Null or wrong content | Use a string or a valid content-part array |
| The model `x` does not exist or you do not have access to it | Typo, retired model, or no access (often 404 with model_not_found) | GET /v1/models and pick a listed id |
Why does the chat model say it is not supported in v1/completions?
OpenAI exposes different endpoints with different payload shapes, and each model family is tied to one or more of them. Sending gpt-3.5-turbo to /v1/completions, or text-davinci-003 to /v1/chat/completions, fails at validation with a 400.
text-davinci-003 and the other original GPT-3 completion models were retired in January 2024, so copied snippets using them now fail with a model error regardless of endpoint.
| Endpoint | Payload | Models |
|---|---|---|
| /v1/chat/completions | messages array | gpt-4o, gpt-4.1, gpt-3.5-turbo, o-series, GPT-5 family and other chat models |
| /v1/completions (legacy) | prompt string | gpt-3.5-turbo-instruct and older base models such as davinci-002, babbage-002 |
| /v1/responses | input (string or items) | Current chat and reasoning models; recommended for new apps |
How do I migrate from completions to chat completions?
Three things change: the URL, the request body (prompt becomes messages), and the response path (choices[0].text becomes choices[0].message.content). Streaming chunks put text in choices[0].delta.content, and the first or last chunk's delta may have no content key, so guard for it.
Parameters best_of, echo and suffix do not exist on chat. Logprobs is a boolean plus top_logprobs (an integer) instead of an integer count.
- Change the endpoint to /v1/chat/completions (or client.chat.completions.create).
- Wrap the old prompt as {"role": "user", "content": prompt}; put standing instructions in a system message (developer on newer models).
- Delete best_of, echo, suffix; convert logprobs to logprobs: true plus top_logprobs.
- Read choices[0].message.content instead of choices[0].text.
- Rename max_tokens to max_completion_tokens if the model is a reasoning model.
# BEFORE (legacy, retired)
resp = openai.Completion.create(
model="text-davinci-003",
prompt="Summarize: ...",
max_tokens=200, temperature=0.2)
text = resp["choices"][0]["text"]
# AFTER (openai >= 1.0)
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a concise assistant."},
{"role": "user", "content": "Summarize: ..."}],
max_tokens=200, temperature=0.2)
text = resp.choices[0].message.content
# Streaming
for chunk in client.chat.completions.create(model="gpt-4o-mini",
messages=[{"role":"user","content":"Hi"}], stream=True):
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")What does a minimal working request look like in curl and Node?
Start from a request that is known good, then add your parameters one at a time. The first one that returns 400 is your culprit, and its error body names the param.
curl -i https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say hello"}]
}'
# Node (openai v4+)
# import OpenAI from "openai"; const client = new OpenAI();
# const r = await client.chat.completions.create({
# model: "gpt-4o-mini",
# messages: [{ role: "user", content: "Say hello" }] });
# console.log(r.choices[0].message.content);Why does my JSON break, and how do I check context length and auth?
Building the body with string concatenation fails when the prompt contains quotes, backslashes or newlines, producing 'could not parse the JSON body'. Use json.dumps, JSON.stringify or the SDK, and in shell write the body to a file. Log the exact serialized body you send, since that is what the server sees.
The context error counts input tokens plus max_tokens (output reservation) against the window. Count input with tiktoken, then leave headroom.
Types matter: temperature must be a number, n and max_tokens integers, messages a non-empty array, and content a string or an array of typed parts (text, image_url, input_audio) only on models that accept them. Sending an image part to a text-only model is a 400.
import json, tiktoken
messages = [{"role": "user", "content": 'He said "hi"\nnew line'}]
# Serialize the same list you count below, so counted and sent content match
body = json.dumps({"model": "gpt-4o-mini", "messages": messages})
enc = tiktoken.encoding_for_model("gpt-4o-mini")
# m["content"] is a string only for plain-text messages. If content is an
# array of parts (text, image_url, ...), extract the text parts first.
prompt_tokens = sum(len(enc.encode(m["content"])) for m in messages)
# add ~4 tokens per message of overhead; keep prompt_tokens + max_tokens < window How do I tell 400 from 401, 404 and 429 quickly?
The status code tells you which layer failed, so you can skip the wrong checklist.
| Status | Meaning | Typical cause |
|---|---|---|
| 400 | Request invalid | Wrong endpoint, payload shape, parameter, types, JSON, context length |
| 401 | Authentication failed | Missing, wrong or revoked key; header not 'Authorization: Bearer KEY'; wrong organization |
| 403 | Not permitted | Unsupported region or project restrictions |
| 404 | Not found | Wrong URL path, or model does not exist or is not accessible to your project |
| 429 | Rate limit or quota | Too many requests or tokens, or no billing credit; back off and retry |
Common questions
Why do I get 'you must provide a messages parameter'?
You sent prompt, or an empty body, to /v1/chat/completions. Chat requires a non-empty messages array such as [{"role":"user","content":"..."}]. Also confirm your HTTP client really sent JSON, since a missing Content-Type can make the body arrive empty.
Can I keep using the prompt parameter?
Only on /v1/completions with a completion-style model such as gpt-3.5-turbo-instruct. Chat and reasoning models require messages, and the legacy endpoint is not where new models are added.
How do I list the models my key can use?
Send GET /v1/models with your Authorization header, or call client.models.list() in the SDK. Use an id exactly as returned; the list reflects your project's access, so a model missing there will fail.
Which roles are allowed in messages?
system, user and assistant work everywhere. Tool results use role tool with a tool_call_id, and newer models accept developer as the instruction role in place of system. An unknown role value triggers a 400 naming the param.
Is retrying a 400 useful?
No. A 400 is deterministic, so the identical request will fail again. Fix the payload; reserve retries with exponential backoff for 429 and 5xx responses.