Managing Responses

Every response is stored by default and can be retrieved or deleted using its id. Stored data is also subject to an automatic retention policy that expires old responses and conversations over time.


Opting Out of Storage

Set store=false to skip persisting a response. The response is returned normally but is not saved to history.

  • curl

  • Python (OpenAI SDK)

curl -X POST $BASE_URL/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $AA_TOKEN" \
  -d '{
    "model": "qwen3-32b-tool",
    "input": "What is 2+2?",
    "store": false
  }'
response = client.responses.create(
    model="qwen3-32b-tool",
    input="What is 2+2?",
    store=False,
)
# Response is returned but not persisted
Impact on conversation chaining

A non-stored response cannot be referenced by a later previous_response_id. Any follow-up call that tries to chain from it will receive a 404 not_found_error, because there is no history entry to look up.

For example, if Turn 2 uses store=false, then Turn 3 cannot set previous_response_id to Turn 2’s ID.

This is useful for one-off queries, e.g. classification or extraction, where you don’t need conversation continuity or later retrieval.


Retrieve a Response

Fetch a stored response by its ID.

  • curl

  • Python (OpenAI SDK)

curl $BASE_URL/v1/responses/resp_abc123 \
  -H "Authorization: Bearer $AA_TOKEN"
response = client.responses.retrieve("resp_abc123")

print(response.id)           # "resp_abc123"
print(response.status)       # "completed"
print(response.output_text)  # The assistant's response text

Response:

{
  "id": "resp_abc123",
  "object": "response",
  "created_at": 1711000000,
  "model": "qwen3-32b-tool",
  "status": "completed",
  "output": [...],
  "usage": {...}
}

Retrieve as Streaming SSE

You can replay a stored response as a Server-Sent Events stream:

curl $BASE_URL/v1/responses/resp_abc123?stream=true \
  -H "Authorization: Bearer $AA_TOKEN"

Not Found

Requesting a non-existent ID returns 404:

{
  "error": {
    "message": "Response with ID 'resp_nonexistent' not found.",
    "type": "not_found_error",
    "param": null,
    "code": "response_not_found"
  }
}

Delete a Response

Remove a stored response when it’s no longer needed.

  • curl

  • Python (OpenAI SDK)

curl -X DELETE $BASE_URL/v1/responses/resp_abc123 \
  -H "Authorization: Bearer $AA_TOKEN"
client.responses.delete("resp_abc123")  # returns None on success

Response:

{
  "id": "resp_abc123",
  "object": "response",
  "deleted": true
}

Deletion Behavior

Deletion follows a retention policy built around two modes: soft delete (the default, reversible) and hard delete (admin-only, permanent). A plain DELETE is always a soft delete, and the 200 body above ("deleted": true) is returned in both modes.

Soft delete (default)

A standard DELETE marks the response as deleted by stamping a deleted_at timestamp. The row remains in the database but becomes invisible to normal access:

  • It can no longer be retrieved via GET /v1/responses/{id} (returns 404).

  • It can no longer be used as a previous_response_id; chaining from it fails with 404 not_found_error.

  • It is excluded from conversation listings.

Cascade to descendants: Soft-deleting a response also soft-deletes every subsequent response in its chain: any response whose ancestor_ids contain the deleted ID. Deleting a parent therefore removes the entire downstream subtree in one call, so you no longer need to delete chains leaf-to-root.

For example, in a chain resp_1 → resp_2 → resp_3, deleting resp_1 soft-deletes resp_1, resp_2, and resp_3 together. Deleting resp_2 removes resp_2 and resp_3 but leaves resp_1 intact.

Soft delete does not affect the parent conversation; the conversation row remains unchanged.

Hard delete (admin only)

Admins can permanently remove a response by passing hard_delete=true. This physically deletes the row(s) from the database; the data is unrecoverable.

  • curl

curl -X DELETE "$BASE_URL/v1/responses/resp_abc123?hard_delete=true" \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN"
  • Hard delete also cascades to all descendants, including ones that were already soft-deleted.

  • A non-admin caller passing hard_delete=true receives 403 (permission_error / insufficient_permissions).

  • If the target response is still being processed in the background, hard delete is blocked with 425 Too Early (too_early_error / response_in_progress). Wait until processing completes or fails, then retry.

Hard delete is irreversible

Hard delete exists for compliance scenarios (e.g. GDPR erasure requests) where data must be physically purged. Once a response is hard-deleted it cannot be recovered. For routine deletions, prefer the default soft delete.

Admin: retrieve a soft-deleted response

Admins can read soft-deleted responses by passing include_deleted=true. This is intended for audit and recovery workflows and is logged.

  • curl

curl "$BASE_URL/v1/responses/resp_abc123?include_deleted=true" \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN"

Non-admin callers passing include_deleted=true receive 403.

Admin: recover a soft-deleted response

Because soft delete is reversible, admins can restore a soft-deleted response with PATCH /v1/responses/{id} and recovery_from_delete=true. Recovery cascades to all soft-deleted descendants, restoring the subtree that was removed together.

  • curl

curl -X PATCH "$BASE_URL/v1/responses/resp_abc123?recovery_from_delete=true" \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN"
  • Returns the recovered response object.

  • Non-admin callers receive 403; omitting recovery_from_delete=true returns 400.

  • Hard-deleted responses cannot be recovered; there is no row left to restore.


Automatic Retention

Beyond the manual DELETE above, stored data is expired automatically by a retention policy. In deployments backed by a database, a background job runs on a schedule and applies the policy to every response and conversation once it is old enough; you don’t need to call DELETE yourself for routine cleanup.

The lifecycle reuses the same soft delete and hard delete mechanics described above:

  1. When a record is older than the retention period (measured from its created_at), the configured expiry action runs.

  2. With the default soft-delete action, the record is stamped with deleted_at (it disappears from normal reads but stays in the database).

  3. If enabled, the record is then hard-deleted once the grace period after soft deletion elapses, physically removing it.

Expiry cascades to descendants exactly like a manual delete.

Default policy

The global defaults are set via environment variables (prefix RETENTION_):

Setting Env var Default Description

Retention period

RETENTION_PERIOD

30d

Age after created_at before the expiry action runs. Durations are human-friendly strings (30d, 6m, 1y).

Expiry action

RETENTION_EXPIRY_ACTION

soft-delete

soft-delete or hard-delete.

Grace period

RETENTION_GRACE_PERIOD

7d

Delay between soft deletion and permanent removal.

Hard-delete after grace

RETENTION_HARD_DELETE_AFTER_GRACE

true

Whether soft-deleted records are hard-deleted once the grace period elapses.

Schedule

RETENTION_SCHEDULE

0 0 * * *

Cron expression controlling when retention processing runs (default: daily at midnight).

With the defaults above, a response is soft-deleted 30 days after creation and permanently removed 7 days later. To keep data indefinitely, raise RETENTION_PERIOD or override the policy per application (below).

Admin: retention policies

Admins can override the global default with a per-application policy through the admin API. Application-level policies take precedence over the global environment defaults, so different applications can have different retention rules.

Access rules mirror the other admin operations:

  • Super-admins (no x-application-id) may target any application via the path/body application_id.

  • Application-admins (with an x-application-id) may only manage their own application; targeting a different one returns 403.

Policy fields (durations are strings like 90d):

Field Type Description

application_id

string | null

Target application. null sets a tenant-wide policy (super-admin only).

retention_period

string

Age before the expiry action runs. Admin-set policies are validated against a 1-day minimum (this floor is not applied to the RETENTION_PERIOD environment default).

expiry_action

string

soft-delete (default) or hard-delete.

grace_period

string

Delay before hard deletion (default 7d).

hard_delete_after_grace

boolean

Hard-delete after grace (default false).

schedule

string

Cron expression (default 0 0 * * *).

Create a policy

  • curl

curl -X POST $BASE_URL/admin/retention-policies \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN" \
  -d '{
    "application_id": "legal-agent",
    "retention_period": "90d",
    "expiry_action": "soft-delete",
    "grace_period": "7d",
    "hard_delete_after_grace": true
  }'

Response (201 Created):

{
  "application_id": "legal-agent",
  "retention_period": "90d",
  "expiry_action": "soft-delete",
  "grace_period": "7d",
  "hard_delete_after_grace": true,
  "schedule": "0 0 * * *",
  "created_at": "2026-07-07T00:00:00Z",
  "updated_at": "2026-07-07T00:00:00Z"
}

Creating a policy for an application that already has one returns 409.

Retrieve the effective policy

Returns the policy currently in effect for an application, including its source (application_admin, super_admin, or global_default). Pass detail=true to also get the full precedence breakdown.

  • curl

curl "$BASE_URL/admin/retention-policies/legal-agent?detail=true" \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN"

Update a policy

A PUT applies a partial update; only the fields you send are changed.

  • curl

curl -X PUT $BASE_URL/admin/retention-policies/legal-agent \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN" \
  -d '{"retention_period": "180d"}'

Updating a non-existent policy returns 404.

Delete a policy

Removes the application-level policy; the application falls back to the global default.

  • curl

curl -X DELETE $BASE_URL/admin/retention-policies/legal-agent \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN"

A separate, feature-flagged admin router (/v1/admin/policies) also exists for policy management and is only registered when enabled in the deployment. The /admin/retention-policies routes documented here are always available.


Response Metadata

You can attach key-value metadata to any response for tagging, filtering, or tracking:

  • curl

  • Python (OpenAI SDK)

curl -X POST $BASE_URL/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $AA_TOKEN" \
  -d '{
    "model": "qwen3-32b-tool",
    "input": "Summarize the quarterly report.",
    "metadata": {
      "team": "finance",
      "request_source": "slack-bot"
    }
  }'
response = client.responses.create(
    model="qwen3-32b-tool",
    input="Summarize the quarterly report.",
    metadata={
        "team": "finance",
        "request_source": "slack-bot",
    },
)

print(response.metadata)  # {"team": "finance", "request_source": "slack-bot"}

Constraints:

Limit Value

Maximum keys

16

Maximum key length

64 characters

Maximum value length

512 characters

Value type

Strings only

Metadata is echoed back in the response object and persisted with the response.


Conversations API

Conversations let you group related responses together and manage them as a unit. There are two ways to use conversations:

  1. Explicitly, create a conversation up front, then reference it in requests via the conversation field.

  2. Implicitly, when a response belongs to a conversation and you chain from it with previous_response_id, the conversation ID is inherited automatically.

Assigning Responses to a Conversation

Pass the conversation field when creating a response to assign it to an existing conversation. You can pass either a bare string or an object:

  • curl

  • Python (OpenAI SDK)

# Assign a response to an existing conversation.
# Replace CONVERSATION_ID with the id of your conversation.
curl -X POST $BASE_URL/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $AA_TOKEN" \
  -d '{
    "model": "qwen3-32b-tool",
    "input": "Hello!",
    "conversation": "CONVERSATION_ID"
  }'
import httpx

# Create a conversation
conv = httpx.post(
    f"{BASE_URL}/v1/conversations",
    headers={"Authorization": f"Bearer {AA_TOKEN}"},
).json()

# Assign a response to it
response = client.responses.create(
    model="qwen3-32b-tool",
    input="Hello!",
    extra_body={"conversation": conv["id"]},
)

When you chain from a response that belongs to a conversation using previous_response_id, the follow-up response automatically inherits the conversation; you don’t need to pass conversation again.

Create a Conversation

  • curl

  • Python

curl -X POST $BASE_URL/v1/conversations \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $AA_TOKEN"
import httpx

conv = httpx.post(
    f"{BASE_URL}/v1/conversations",
    headers={"Authorization": f"Bearer {AA_TOKEN}"},
).json()

print(conv["id"])  # "conv_abc123"

Response:

{
  "id": "conv_abc123",
  "object": "conversation",
  "metadata": {},
  "created_at": 1711000000,
  "updated_at": 1711000000
}

You can optionally pass metadata (key-value string pairs, max 16 keys) on create or update it later via POST /v1/conversations/{id}. Metadata follows the same constraints as response metadata.

Retrieve a Conversation

  • curl

  • Python

curl $BASE_URL/v1/conversations/{conversation_id} \
  -H "Authorization: Bearer $AA_TOKEN"
import httpx

conv = httpx.get(
    f"{BASE_URL}/v1/conversations/{conversation_id}",
    headers={"Authorization": f"Bearer {AA_TOKEN}"},
).json()

List Conversations

  • curl

  • Python

curl "$BASE_URL/v1/conversations?limit=10&offset=0&order=desc" \
  -H "Authorization: Bearer $AA_TOKEN"
import httpx

result = httpx.get(
    f"{BASE_URL}/v1/conversations",
    params={"limit": 10, "offset": 0, "order": "desc"},
    headers={"Authorization": f"Bearer {AA_TOKEN}"},
).json()

for conv in result["data"]:
    print(conv["id"], conv["metadata"])

Response:

{
  "object": "list",
  "data": [
    {
      "id": "conv_abc123",
      "object": "conversation",
      "metadata": {},
      "created_at": 1711000000,
      "updated_at": 1711000000
    }
  ],
  "has_more": false,
  "first_id": "conv_abc123",
  "last_id": "conv_abc123"
}
Parameter Type Default Description

limit

integer

20

Maximum conversations to return (max 100)

offset

integer

0

Number of conversations to skip

order

string

desc

Sort by updated_at: "asc" or "desc"

metadata.application

string

none

Return only conversations with matching metadata.application

include_deleted

boolean

false

Admin only. Include soft-deleted conversations in the list. Non-admin callers passing true receive 403.

To list conversations for one app, create conversations with an application metadata value and pass the same value as metadata.application when listing.

  • curl

  • Python

curl -X POST $BASE_URL/v1/conversations \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $AA_TOKEN" \
  -d '{"metadata": {"application": "legal-agent"}}'

curl "$BASE_URL/v1/conversations?metadata.application=legal-agent" \
  -H "Authorization: Bearer $AA_TOKEN"
import httpx

httpx.post(
    f"{BASE_URL}/v1/conversations",
    json={"metadata": {"application": "legal-agent"}},
    headers={"Authorization": f"Bearer {AA_TOKEN}"},
)

result = httpx.get(
    f"{BASE_URL}/v1/conversations",
    params={"metadata.application": "legal-agent"},
    headers={"Authorization": f"Bearer {AA_TOKEN}"},
).json()

List Responses in a Conversation

Fetch all responses belonging to a conversation. Each item includes the full response data plus tree metadata (ancestor_ids, depth) and the original request_input.

  • curl

  • Python

# Ascending order (default)
curl "$BASE_URL/v1/conversations/{conversation_id}/responses" \
  -H "Authorization: Bearer $AA_TOKEN"

# Descending order
curl "$BASE_URL/v1/conversations/{conversation_id}/responses?order=desc" \
  -H "Authorization: Bearer $AA_TOKEN"

# Admin only: include soft-deleted responses
curl "$BASE_URL/v1/conversations/{conversation_id}/responses?include_deleted=true" \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN"
import httpx

response = httpx.get(
    f"{BASE_URL}/v1/conversations/{conversation_id}/responses",
    headers={"Authorization": f"Bearer {AA_TOKEN}"},
)

data = response.json()
for item in data["data"]:
    print(f"{item['id']} (depth={item['depth']}): {item['status']}")

Response:

{
  "object": "list",
  "data": [
    {
      "id": "resp_001",
      "object": "response",
      "status": "completed",
      "model": "qwen3-32b-tool",
      "output": [...],
      "ancestor_ids": [],
      "depth": 0,
      "request_input": [{"type": "message", "role": "user", "content": "Hello"}]
    },
    {
      "id": "resp_002",
      "object": "response",
      "status": "completed",
      "model": "qwen3-32b-tool",
      "output": [...],
      "ancestor_ids": ["resp_001"],
      "depth": 1,
      "request_input": [{"type": "message", "role": "user", "content": "Follow up"}]
    }
  ]
}
Parameter Type Default Description

order

string

asc

Sort by created_at: "asc" or "desc"

include_deleted

boolean

false

Admin only. Include soft-deleted responses in the list. Non-admin callers passing true receive 403.

Delete a Conversation

  • curl

  • Python

curl -X DELETE $BASE_URL/v1/conversations/{conversation_id} \
  -H "Authorization: Bearer $AA_TOKEN"
import httpx

httpx.delete(
    f"{BASE_URL}/v1/conversations/{conversation_id}",
    headers={"Authorization": f"Bearer {AA_TOKEN}"},
)

Response:

{
  "id": "conv_abc123",
  "object": "conversation",
  "deleted": true
}

Conversation deletion follows the same retention policy as responses, with the same options: soft delete is the default, hard_delete=true and recovery are admin-only, and admins can read soft-deleted data with include_deleted=true (see List Responses in a Conversation).

The key difference: deleting a single response only cascades to that response’s descendants (later responses in its chain), leaving its ancestors intact. Deleting a conversation instead removes every response in it, the entire response tree, including ancestors, not just one chain.

  • The conversation and all its responses are marked as deleted (cascade) but remain in the database.

  • Soft-deleted responses cannot be retrieved via GET /v1/responses/{id} or used as previous_response_id.

  • You do not need to delete individual responses first; the cascade handles it.

  • Deleting an already-deleted or non-existent conversation returns 404.

Hard delete (admin only)

Admins can permanently remove a conversation and every history entry it owns by passing hard_delete=true:

  • curl

curl -X DELETE "$BASE_URL/v1/conversations/{conversation_id}?hard_delete=true" \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN"
  • The conversation row and all associated responses are physically deleted and cannot be recovered.

  • A non-admin caller passing hard_delete=true receives 403.

  • If any response in the conversation is still being processed in the background, hard delete is blocked with 425 Too Early (conversation_has_in_progress_responses).

Recovery (admin only)

A soft-deleted conversation can be restored with PATCH /v1/conversations/{id} and recovery_from_delete=true. Recovery restores the conversation and cascades to its soft-deleted responses.

  • curl

curl -X PATCH "$BASE_URL/v1/conversations/{conversation_id}?recovery_from_delete=true" \
  -H "Authorization: Bearer $AA_ADMIN_TOKEN"

Non-admin callers receive 403; hard-deleted conversations cannot be recovered.