← Blog

Claude API Conversation History Storage: DB Design Guide

2026-10-25 · 5 min read · SubToAPI Team

The Claude API is stateless: every request must include the full conversation history in the messages array, and Anthropic does not store or retrieve past turns for you. If you want persistent conversations — chat history that survives a page reload, a server restart, or a user coming back tomorrow — you need to store that history yourself in a database, and reconstruct the messages array on every request.

This is a common point of confusion for teams building chat products on top of Claude. There's no "conversation ID" you pass to the API to resume a thread. Instead, your application owns the conversation state: you persist each message as it's created, and when the user sends a new one, you fetch the relevant history, append the new message, and send the whole thing back to the model. This article covers the schema, the retrieval strategy, and the tradeoffs that matter once conversations get long.

Minimum viable schema

For most applications, two tables are enough: conversations and messages.

CREATE TABLE conversations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL,
  title TEXT,
  system_prompt TEXT,
  model TEXT NOT NULL DEFAULT 'claude-sonnet-4-5',
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE messages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
  role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
  content JSONB NOT NULL,
  input_tokens INT,
  output_tokens INT,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_messages_conversation ON messages(conversation_id, created_at);

A few deliberate choices here:

Reconstructing the messages array

When the user sends a new turn, pull the ordered history and append the new user message:

async function getMessagesForClaude(conversationId) {
  const rows = await db.query(
    `SELECT role, content FROM messages
     WHERE conversation_id = $1
     ORDER BY created_at ASC`,
    [conversationId]
  );
  return rows.map(r => ({ role: r.role, content: r.content }));
}

const history = await getMessagesForClaude(conversationId);
history.push({ role: 'user', content: userInput });

const response = await fetch('https://api.subtoapi.app/v1/messages', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.SUBTOAPI_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    model: 'claude-sonnet-4-5',
    max_tokens: 1024,
    messages: history
  })
});

After the response comes back, write both the user message and the assistant message to the database in the same transaction, so a failed write never leaves the conversation in an inconsistent state (user message saved, assistant reply lost).

Handling growing context

Conversations that run long eventually exceed what's practical — or affordable — to send on every turn. There are three common strategies, often combined:

  1. Sliding window: keep only the last N messages, drop older ones. Simple, but loses early context.
  2. Summarization: periodically compress older turns into a short summary stored as a system-level note, then drop the raw messages. More complex, but preserves intent without the token cost.
  3. Full history with truncation fallback: store everything, but only load the full set when it fits within your target token budget; fall back to summarization or windowing past that.

Whichever you choose, store the raw message content permanently even if you don't send all of it to the model. You'll want it for debugging, analytics, or re-processing later — regenerating a summary from a sliding window you already discarded data from isn't possible.

Don't forget tool use and multi-turn blocks

If your app uses Claude's tool-calling, a single logical "turn" can span multiple messages: an assistant message with a tool_use block, followed by a user message carrying the tool_result. Store these exactly as the API returns and sends them — don't collapse them into a single row, or you'll break replay when reconstructing history. See /docs/tools and /docs/messages for the exact content block shapes to persist.

Where SubToAPI fits

The storage and schema design above is the same whether you call Claude directly or through a proxy. What changes is everything around the API call: SubToAPI sits in front of your Claude access and gives you an HTTPS endpoint with application-scoped sub_live_... keys, so each app or environment gets its own key instead of sharing one Anthropic credential across your codebase.

That matters for conversation storage specifically because SubToAPI returns usage metadata on every response — the same input_tokens / output_tokens fields shown above — so you can log cost per conversation, per user, or per team seat without building your own token counter. If you're running a multi-tenant chat product, pairing your own conversations/messages tables with SubToAPI's per-key usage tracking gives you both sides of the picture: what was said, and what it cost. Check /docs/quickstart to get a key, and /docs/streaming if your chat UI streams responses token by token — you'll still want to persist the final assembled message once the stream completes.

questions

Does the Claude API store conversation history for me? No. Anthropic's API is stateless — there's no server-side thread or conversation ID. Every request must include the complete message history you want the model to see, so your application is responsible for persisting and reassembling it.

What format should I use to store Claude messages in a database? Store the role and content fields as returned/sent, with content as JSON (not flattened text), since it can contain structured blocks like tool use, tool results, and images. Track token usage per message for cost reporting.

How do I handle conversations that get too long for the context window? Combine strategies: keep a sliding window of recent messages for immediate context, periodically summarize older turns into a compact note, and always retain the full raw history in your database even if you don't send all of it to the model on every call.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →