Howwise RSS
AI & Automation6 min read

OpenAI Chat Completions 400 Bad Request: Causes and Fixes

Short answer

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.

Key takeaways
  • 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.json
Tip: Do not use curl -f or --fail while debugging: it suppresses the response body on HTTP errors, which is exactly what you need.

Which 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)CauseFix
This is a chat model and not supported in the v1/completions endpointChat model sent to /v1/completionsCall /v1/chat/completions with messages
... is not a chat model / not supported in the v1/chat/completions endpointLegacy completion model on the chat endpointUse a chat model, or the completions endpoint for instruct models
you must provide a messages parameter / 'messages' is a required propertySent prompt (or nothing) to the chat endpointSend messages: [{role, content}]
Unrecognized request argument supplied: prompt (or best_of, echo, suffix)Legacy parameter on chat endpointRemove it; move prompt text into messages
We could not parse the JSON body of your requestBroken JSON or missing Content-TypeSerialize with json.dumps; set Content-Type: application/json
This model's maximum context length is N tokens. However, you requested MPrompt tokens plus max_tokens exceed windowTrim input, lower max_tokens, or use a larger-context model
Unsupported parameter: 'max_tokens' ... Use 'max_completion_tokens' insteadReasoning model (o-series, newer GPT-5 family)Rename to max_completion_tokens
Unsupported value: 'temperature' does not support 0.2 with this modelModel fixes sampling settingsOmit temperature (and top_p) for that model
Invalid type for 'temperature': expected a decimal number, but got a stringWrong JSON typeSend numbers as numbers, not strings
Invalid value for 'content' / expected a string, got nullNull or wrong contentUse a string or a valid content-part array
The model `x` does not exist or you do not have access to itTypo, 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.

EndpointPayloadModels
/v1/chat/completionsmessages arraygpt-4o, gpt-4.1, gpt-3.5-turbo, o-series, GPT-5 family and other chat models
/v1/completions (legacy)prompt stringgpt-3.5-turbo-instruct and older base models such as davinci-002, babbage-002
/v1/responsesinput (string or items)Current chat and reasoning models; recommended for new apps
Tip: Moving to chat models is the supported path. gpt-3.5-turbo-instruct is the only drop-in if you must keep the prompt-string format, and it is not a long-term choice.

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.

  1. Change the endpoint to /v1/chat/completions (or client.chat.completions.create).
  2. Wrap the old prompt as {"role": "user", "content": prompt}; put standing instructions in a system message (developer on newer models).
  3. Delete best_of, echo, suffix; convert logprobs to logprobs: true plus top_logprobs.
  4. Read choices[0].message.content instead of choices[0].text.
  5. 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="")
Tip: New projects can use the Responses API (client.responses.create(model=..., input=...), text via resp.output_text). Chat Completions remains supported, so migrating to it is still a valid fix for a 400.

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);
Warning: Never paste a real key into shared logs or code you commit. If you print the request for debugging, redact the Authorization header.

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.

StatusMeaningTypical cause
400Request invalidWrong endpoint, payload shape, parameter, types, JSON, context length
401Authentication failedMissing, wrong or revoked key; header not 'Authorization: Bearer KEY'; wrong organization
403Not permittedUnsupported region or project restrictions
404Not foundWrong URL path, or model does not exist or is not accessible to your project
429Rate limit or quotaToo many requests or tokens, or no billing credit; back off and retry
Tip: Final checklist: read the body, check URL against model, check messages, strip legacy parameters, validate types, count tokens, confirm the model appears in GET /v1/models, then retry the minimal curl.

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.

Related topics:openaichat-completionsapi-errorspython