OpenAI Responses API previous_response_id & Conversations State Errors — Fix Guide (2026)
Responses API · State Management Severity: High 400

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.

TL;DRChain calls via 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

previous_response_id not found
previous_response_id not found
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.
BadRequestError — cannot use both
BadRequestError — cannot use both
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.
Cross-user context leaked
Cross-user context leaked
# 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

MechanismBest forWatch out for
previous_response_idShort flows, agent step-chains, ephemeral sessions30-day expiry; store=True required
Conversations APILong-lived per-user chats, multi-device continuationRetention & compliance; per-user isolation required
Client-managed historyWhen retention rules forbid server-side storageYou own token growth; pass full history every call
Mix any two400: 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=False then chaining. The response wasn't persisted; no chain possible. First call must have store=True (the default) to be resumable.
  • 15%
    Mixing previous_response_id and conversation. 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_id used 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_id keeps 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.2 to one requested with gpt-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

Fix #1

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.

previous_response_id_chain.pypython
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
Note: Store the latest response ID in your session record, not every intermediate one. The chain is a linked list; you only need the head to walk backward. When a user returns after a long absence, wrap the chain call in the fallback shown so expired IDs don't hard-fail.
Fix #2

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.

conversations_api.pypython
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)}
Note: The Conversations API is the closest replacement for Assistants API threads. When migrating from Assistants (see #148), each Thread becomes a Conversation with the same isolation semantics — one per user session.
Fix #3

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.

client_managed_state.pypython
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
Note: When 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_id chains, keep store=True (the default) and store only the latest response ID in your session record.
  • Wrap chained calls in a NotFoundError fallback — expired IDs should start a new chain, not hard-fail.
  • Never share a conversation_id across 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

How long do stored responses live?

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.

Can I convert a previous_response_id chain into a Conversation?

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.

Does previous_response_id include reasoning tokens for o-series models?

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.

What's the difference between metadata on a response vs on a conversation?

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.

Can two clients chain from the same response ID in parallel?

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.

Related errors