OpenAI Responses API previous_response_id & Conversations State Errors
You have three ways to carry state on the Responses API — previous_response_id, the Conversations API, or client-side history — and each has a specific failure mode in production. Here's the full state model with the errors that follow when you get it wrong.
By Ahmed R. · Last updated Aug 14, 2026 · OpenAI · Page #137
previous_response_id for simple flows (requires store=True, the default). Use the Conversations API when state is per-user and long-lived (conversation parameter). Never mix previous_response_id and conversation on the same call — 400 with "cannot use both". Stored responses expire after 30 days unless you set metadata.Real error messages you'll see
openai.NotFoundError: Error code: 404 - {'error': {'message': "Response with id 'resp_abc123' not found. It may have been deleted, expired, or was created with store=False.", 'type': 'invalid_request_error'}}
# You tried to chain from a response that either was never stored (store=False), expired (>30 days), or belongs to a different project/org.
openai.BadRequestError: Error code: 400 - {'error': {'message': "The parameter 'previous_response_id' cannot be used together with 'conversation'. Use one or the other.", 'type': 'invalid_request_error'}}
# The two state mechanisms are mutually exclusive. Pick one.
# Symptom — user A sees fragments of user B's prior chat.
# Root cause: single shared conversation_id used for multiple users, or previous_response_id from a shared pool.
# Fix: one conversation_id per user session, or one chain of previous_response_id per user.
State mechanism trade-offs
| Mechanism | Best for | Watch out for |
|---|---|---|
previous_response_id | Short flows, agent step-chains, ephemeral sessions | 30-day expiry; store=True required |
| Conversations API | Long-lived per-user chats, multi-device continuation | Retention & compliance; per-user isolation required |
| Client-managed history | When retention rules forbid server-side storage | You own token growth; pass full history every call |
| Mix any two | — | 400: cannot use both together |
Root causes (ranked by frequency)
Based on OpenAI developer reports; percentages sum to 100%.
- 22%Chaining from a response that expired. Default retention on stored responses is 30 days. A resumed session after that window returns 404 on
previous_response_id. - 18%
store=Falsethen chaining. The response wasn't persisted; no chain possible. First call must havestore=True(the default) to be resumable. - 15%Mixing
previous_response_idandconversation. They're mutually exclusive per call. Pick one per code path and never combine on the same request. - 12%Cross-project or cross-org access. Response IDs are scoped to the project that created them. Calling from a different project's key returns 404 even if the ID is valid.
- 10%Cross-user context bleed via a shared conversation_id. One
conversation_idused across multiple end-users; each user sees the others' prior turns. Create one conversation per user session. - 8%Very long chains drift into stale context. Chaining 50+ turns via
previous_response_idkeeps sending everything to the model — you hit context-window limits eventually. Summarize periodically. - 8%Chain across model changes. Chaining from a response generated by
gpt-5.2to one requested withgpt-4.1. Works, but reasoning items from o-series models are dropped when chaining to non-reasoning models. - 7%Metadata not set — no way to find responses later. Without
metadata={"user_id": "..."}, you can't filter or list responses meaningfully via the dashboard or API.
How to fix it
Use previous_response_id for short sessions — verify store=True and handle expiry
The right shape for short agent chains and ephemeral flows.
For a chat that lasts minutes to a few days, previous_response_id is the leanest option. Every call implicitly stores its response (unless store=False); pass the last response's ID to continue. Handle expiry gracefully — after 30 days, chained calls 404 and you either fall back to client-managed history or start a new chain.
from openai import OpenAI, NotFoundError
client = OpenAI()
def chat_turn(user_msg: str, prev_id: str | None) -> tuple[str, str]:
"""Return (assistant_text, new_response_id). Handles expired chain gracefully."""
try:
resp = client.responses.create(
model="gpt-5.4",
input=user_msg,
previous_response_id=prev_id, # None on first call
store=True, # explicit (default) — required for chaining
metadata={"user_id": "user-42", "purpose": "onboarding_chat"},
)
return resp.output_text, resp.id
except NotFoundError as e:
# prev_id expired, deleted, or belongs to another project
# Fall back: start a fresh chain
if prev_id is not None:
resp = client.responses.create(
model="gpt-5.4",
input=user_msg,
store=True,
metadata={"user_id": "user-42", "resumed_after_expiry": True},
)
return resp.output_text, resp.id
raise
# Turn 1 — no previous
text_1, id_1 = chat_turn("Hi, I need help setting up my account.", None)
# Turn 2 — chain from turn 1
text_2, id_2 = chat_turn("What info do you need from me?", id_1)
# Turn 3 — chain from turn 2
text_3, id_3 = chat_turn("Great, my email is sana@example.com.", id_2)
# ✅ Persist the LAST response ID in your DB, keyed by session
# When the user returns, load id_3 and continue from there
# On 404, the fallback above starts fresh — user sees a slight
# context reset but the chat continues instead of erroring out
# ✅ To prevent expiry for a specific chain, refresh the head periodically
# Reading a stored response resets its retention (behavior may vary; verify)
_ = client.responses.retrieve(id_3) # touches the resource
# ✅ Force delete when you no longer need the chain (retention hygiene)
client.responses.delete(id_1) # only need to keep the tail; deleting mid-chain
# invalidates future references to id_2 too
Use the Conversations API for per-user, long-lived state — one conversation per user session
The correct shape for chat products with persistent identity.
The Conversations API is a dedicated resource for persistent chat state. Create one conversation per end-user (or per chat if a user has many), pass conversation=conv.id on every Responses call, and OpenAI keeps the history server-side. Critical: never share a conversation_id across users — that leaks context between them.
from openai import OpenAI
client = OpenAI()
# ✅ One conversation per user session — create at signup or first chat
def get_or_create_conversation_for_user(user_id: str, chat_id: str) -> str:
"""Return an existing conversation_id or create a new one."""
# In production, look this up in your DB first
existing = your_db.get_conversation_id(user_id=user_id, chat_id=chat_id)
if existing:
return existing
conv = client.conversations.create(
metadata={
"user_id": user_id,
"chat_id": chat_id,
"created_via": "web_app",
},
)
your_db.save_conversation_id(user_id=user_id, chat_id=chat_id, conv_id=conv.id)
return conv.id
# ✅ Every turn uses the same conversation_id for this user
def user_turn(user_id: str, chat_id: str, msg: str) -> str:
conv_id = get_or_create_conversation_for_user(user_id, chat_id)
resp = client.responses.create(
model="gpt-5.4",
input=msg,
conversation=conv_id, # ← state resumes from here
instructions="You are the personal assistant for this user.",
)
return resp.output_text
# ✅ Inspect what's in a conversation (audit, debugging)
def dump_conversation(conv_id: str, limit: int = 100):
items = client.conversations.items.list(conversation_id=conv_id, limit=limit)
for item in items.data:
print(item.type, item.id, getattr(item, "created_at", ""))
# ✅ Add items manually (import history from another system)
client.conversations.items.create(
conversation_id="conv_abc",
items=[
{"type": "message", "role": "user", "content": "Hi from imported history"},
{"type": "message", "role": "assistant", "content": "Welcome back."},
],
)
# ✅ Delete a conversation entirely (GDPR/right-to-be-forgotten)
client.conversations.delete("conv_abc")
# ❌ ANTI-PATTERN — shared conversation for multiple users
# GLOBAL_CONV = client.conversations.create().id # DO NOT DO THIS
# @api.post("/chat")
# def chat(msg: str, user_id: str):
# return client.responses.create(
# model="gpt-5.4", input=msg,
# conversation=GLOBAL_CONV, # every user sees every other user's history
# )
# ✅ FastAPI pattern
from fastapi import FastAPI
api = FastAPI()
@api.post("/chat/{user_id}/{chat_id}")
async def chat(user_id: str, chat_id: str, message: str):
return {"reply": user_turn(user_id, chat_id, message)}
Manage state client-side when you cannot store server-side
Fixes retention-policy failures and gives full compliance control.
For high-security or regulated domains where server-side storage is a compliance issue, set store=False and pass the full conversation history in input every call. This is functionally identical to Chat Completions' original model — you own token cost, growth, and retention.
from openai import OpenAI
client = OpenAI()
# ✅ Client-managed history — no server-side persistence
class Session:
def __init__(self, system: str):
self.instructions = system
self.history: list[dict] = [] # [{"role": "user"|"assistant", "content": "..."}]
def send(self, user_msg: str, model: str = "gpt-5.4") -> str:
self.history.append({"role": "user", "content": user_msg})
# Trim if history is getting long (compliance-friendly cap)
trimmed = self._trim(self.history)
resp = client.responses.create(
model=model,
instructions=self.instructions,
input=trimmed,
store=False, # no server-side storage
max_output_tokens=800,
)
reply = resp.output_text
self.history.append({"role": "assistant", "content": reply})
return reply
@staticmethod
def _trim(hist: list[dict], max_chars: int = 60_000) -> list[dict]:
"""Trim from oldest to keep total under max_chars. Keep the last turn intact."""
total = sum(len(m["content"]) for m in hist)
if total <= max_chars:
return hist
# Drop oldest pairs until under limit; always keep the last user message
kept = list(hist)
while sum(len(m["content"]) for m in kept) > max_chars and len(kept) > 2:
kept = kept[2:] # drop oldest user+assistant pair
return kept
# Usage
sess = Session(system="You are a HIPAA-compliant patient-intake bot. No PHI leaves this session.")
print(sess.send("I have a headache."))
print(sess.send("It started this morning."))
print(sess.send("What should I do?"))
# sess.history is the source of truth; nothing is stored server-side
# ✅ Persist ONLY on your side (encrypted DB, audit-logged, etc.)
def save_session_to_db(user_id: str, sess: Session):
encrypted = encrypt(json.dumps(sess.history))
db.set(f"session:{user_id}", encrypted)
def restore_session_from_db(user_id: str, system: str) -> Session:
raw = db.get(f"session:{user_id}")
sess = Session(system=system)
if raw:
sess.history = json.loads(decrypt(raw))
return sess
store=False, features that depend on server-side state don't work: no previous_response_id chain, no built-in tool memory across calls, no dashboard history for debugging. The trade-off is full control over what leaves your infrastructure.Prevention checklist
- Pick ONE state mechanism per code path —
previous_response_id, Conversations API, or client-managed. Never mix on a single call. - For
previous_response_idchains, keepstore=True(the default) and store only the latest response ID in your session record. - Wrap chained calls in a
NotFoundErrorfallback — expired IDs should start a new chain, not hard-fail. - Never share a
conversation_idacross users. One conversation per user session, minimum. - Attach
metadata={"user_id": ...}to every stored response — critical for debugging and support lookups. - For long chains (>50 turns), periodically summarize and start a new chain from the summary — otherwise context-window growth catches up.
- For compliance-restricted domains, use
store=False+ client-managed history + encrypted persistence.
Frequently asked questions
Default retention is 30 days from creation. After that, previous_response_id lookups return 404. You can inspect and delete stored responses via the dashboard or client.responses.list() / client.responses.delete(). Enterprise plans may configure different retention windows — check with your account.
Not directly. There's no built-in "convert chain to conversation" migration. If you need to move a chain into the Conversations API, fetch the responses via client.responses.retrieve(id), walk them to reconstruct the message history, create a new conversation, and import items with conversations.items.create. Do this before the chain expires.
Yes — chained calls include the prior reasoning items when the same model family is used, which is a big win for o-series performance (the model doesn't re-derive reasoning). Chaining from an o-series response to a non-reasoning model drops the reasoning items automatically. Chaining the other way (non-reasoning → o-series) works but the o-series model has nothing prior to build on.
Both are arbitrary key/value strings you attach for your own use. Response metadata is useful for tagging individual calls (which feature triggered it, request ID, etc.); conversation metadata is useful for tagging the user/session as a whole (user_id, chat_id, product_area). Both are queryable and returned in the response object. Total metadata size is capped per resource — check current limits.
Yes — response IDs are read-only references. Both clients can pass the same previous_response_id and each gets its own new response chained off it. This is how you fork a conversation for A/B experiments, or resume from a checkpoint after an error. The parent response isn't mutated; both children exist independently.