This is the full developer documentation for helping.io
# Quickstart
> Create an API key, send your first request to the helping.io API, and read the reply. Five minutes, any language.
## Before you begin
[Section titled “Before you begin”](#before-you-begin)
* **A payment card.** Starting your plan opens a checkout page that asks for one.
* **The owner role in your organization.** Only the organization’s owner can start the plan. Signing up creates an organization with you as its owner; if you were invited to someone else’s organization, ask its owner to start the plan.
## Make your first request
[Section titled “Make your first request”](#make-your-first-request)
1. **Create an account and start your plan.**
Sign up at [app.helping.io](https://app.helping.io/signup), then open the link in the verification email. The app then asks you to start your plan. Your first plan begins with a 14-day free trial: you enter a card on Stripe’s checkout page, and the plan is charged when the trial ends unless you cancel first. Pricing is on [helping.io](https://helping.io).
2. **Add credits.**
Requests are paid for in credits, and a request with no credits behind it is refused before it runs. Leave **Add credits now** checked when you start your plan to buy them at the same checkout, or add them later from **Settings → Billing**. See [Credits and billing](/billing/credits/).
3. **Create an API key.**
Open **Settings → API Keys** in the app and create a key. Owners and admins of the organization can create keys. The full secret, which starts with `sk_`, is shown once and cannot be recovered afterwards. Store it somewhere safe; if you lose it, rotate the key. See [API keys](/getting-started/authentication/).
```bash
export HELPINGIO_API_KEY="sk_…"
```
4. **Send a request.**
Point any Responses API client at `https://api.helping.io/v1`.
* curl
```bash
curl https://api.helping.io/v1/responses \
-H "Authorization: Bearer $HELPINGIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-sonnet-5",
"input": "Say hello in exactly 3 words."
}'
```
* Node
```bash
npm install openai
```
```js
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.helping.io/v1",
apiKey: process.env.HELPINGIO_API_KEY,
});
const response = await client.responses.create({
model: "anthropic/claude-sonnet-5",
input: "Say hello in exactly 3 words.",
});
console.log(response.id);
console.log(response.output_text);
```
* Python
```bash
pip install openai
```
```python
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.helping.io/v1",
api_key=os.environ["HELPINGIO_API_KEY"],
)
response = client.responses.create(
model="anthropic/claude-sonnet-5",
input="Say hello in exactly 3 words.",
)
print(response.id)
print(response.output_text)
```
If the request is refused, see [If your first request fails](#if-your-first-request-fails).
5. **Read the reply.**
The response object’s `output` holds the assistant’s message, and `usage` holds the tokens you were charged for. The SDKs also give you the reply’s text on its own as `output_text`. The model writes a new reply each time, so your text will differ from the one below.
```json
{
"background": false,
"completed_at": 1767225600,
"conversation": null,
"created_at": 1767225600,
"error": null,
"frequency_penalty": 0.0,
"id": "resp_ZXhhbXBsZS9yZXNwL2ZpcnN0LXR1cm4AAAAAAAAAAAAAAAAAAAAAAA",
"incomplete_details": null,
"instructions": null,
"max_output_tokens": null,
"max_tool_calls": null,
"metadata": null,
"model": "anthropic/claude-sonnet-5",
"object": "response",
"output": [
{
"content": [
{
"annotations": [],
"logprobs": [],
"text": "Hello there, friend.",
"type": "output_text"
}
],
"id": "msg_00000000000000000000000000000001",
"role": "assistant",
"status": "completed",
"type": "message"
}
],
"parallel_tool_calls": true,
"presence_penalty": 0.0,
"previous_response_id": null,
"prompt_cache_key": null,
"reasoning": null,
"safety_identifier": null,
"service_tier": "default",
"status": "completed",
"store": true,
"temperature": 1.0,
"text": {
"format": {
"type": "text"
}
},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled",
"usage": {
"input_tokens": 100,
"input_tokens_details": {
"cache_write_tokens": 0,
"cached_tokens": 0
},
"output_tokens": 20,
"output_tokens_details": {
"reasoning_tokens": 0
},
"total_tokens": 120
}
}
```
6. **Continue the conversation.**
Send the next turn with `previous_response_id` set to the `id` of the response you just got. You send only the new turn: the API keeps the conversation history.
* curl
Set `RESPONSE_ID` to the `id` from the reply in step 4. It starts with `resp_`.
```bash
curl https://api.helping.io/v1/responses \
-H "Authorization: Bearer $HELPINGIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"previous_response_id": "'"$RESPONSE_ID"'",
"input": "And again."
}'
```
* Node
Add this to the end of the script from step 4:
```js
const next = await client.responses.create({
previous_response_id: response.id,
input: "And again.",
});
console.log(next.output_text);
```
* Python
Add this to the end of the script from step 4:
```python
next_response = client.responses.create(
previous_response_id=response.id,
input="And again.",
)
print(next_response.output_text)
```
Which model?
`GET /v1/models` lists every model you can select, as `provider/model` ids. See [Choose a model](/gateway/choose-a-model/).
## If your first request fails
[Section titled “If your first request fails”](#if-your-first-request-fails)
These are the refusals a new account most often meets. None of them creates or charges anything, and retrying without the fix gets the same answer.
| Status | `code` | Meaning |
| ------ | ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `401` | `invalid_api_key` | The Authorization header is missing, malformed, or names a key that is not usable; check the key. |
| `402` | `payment_method_required` | The organization has no payment method on file, so no request can be served; add one in the billing settings. |
| `429` | `insufficient_quota` | The organization has no active plan, or no credit balance to pay for the request; the message says which. |
* **`invalid_api_key`**: check that `HELPINGIO_API_KEY` holds the whole secret you copied when you created the key, and that the key is not revoked. A lost secret cannot be shown again; rotate the key or create a new one.
* **`payment_method_required`**: the organization’s owner adds a card in **Settings → Billing**: **Start a plan** if there is no plan yet, otherwise **Manage billing**.
* **`insufficient_quota`**: the message says whether the organization has no active plan or no credit balance. The owner starts the plan, or buys credits, in **Settings → Billing**.
Every other code is on [Errors](/gateway/errors/).
## Where next
[Section titled “Where next”](#where-next)
* [Create a response](/gateway/create-a-response/) covers every request field and how input becomes a turn.
* [Streaming](/gateway/streaming/) for tokens as they arrive.
* [Function calling](/gateway/function-calling/) to give the model tools your code executes.
* [Errors](/gateway/errors/) and [Rate limits](/billing/rate-limits/), before you ship.
# API keys
> Create, check, rotate and revoke the sk_ API keys that authenticate every request, and what each failure looks like.
Every `/v1` request authenticates with an API key sent as a bearer token:
```http
Authorization: Bearer sk_…
```
Keys are managed in the app at [app.helping.io](https://app.helping.io), under **Settings → API Keys**, not through `/v1`. Owners and admins of the organization can create, rotate and revoke keys; members cannot open the page.
## Create a key
[Section titled “Create a key”](#create-a-key)
1. Open **Settings → API Keys**.
2. Under **Create a key**, enter a **Name** of at least three characters that says which integration will use it, then select **Create key**.
3. Copy the secret with **Copy**. It starts with `sk_` and is shown once: it cannot be recovered after you select **Dismiss** or leave the page.
4. Store it where your code reads it, for example:
```bash
export HELPINGIO_API_KEY="sk_…"
```
The key is issued to you and belongs to your organization. Spend is attributed per key, so give each integration its own.
## Check a key
[Section titled “Check a key”](#check-a-key)
Listing models proves a key works without running a model, so it costs nothing and is not rate limited.
```bash
curl https://api.helping.io/v1/models \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
```
A working key answers `200` with the list of models. A key the API does not accept, because it is mistyped, incomplete or revoked, answers `401`:
```json
{
"error": {
"message": "invalid API key",
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
}
}
```
A request with no `Authorization` header, or one that is not `Bearer` followed by a key, gets the same `401` with the message `missing or malformed Authorization header`.
## Rotate a key
[Section titled “Rotate a key”](#rotate-a-key)
Rotating a key gives it a new secret. The key keeps its name, its spend history and its conversations.
1. Open **Settings → API Keys** and find the key in the list.
2. Open the key’s actions menu and select **Rotate**.
3. Under **Keep the current secret working for**, choose **Stop it now**, **1 hour**, **24 hours** or **7 days**. A grace period lets your clients switch to the new secret without downtime.
4. Select **Rotate key**, then copy the new secret. It is shown once.
## Revoke a key
[Section titled “Revoke a key”](#revoke-a-key)
Revoking a key stops it immediately and cannot be undone. Its spend history stays visible in the app, and conversations it started can no longer be continued by anyone.
1. Open **Settings → API Keys** and find the key in the list.
2. Open the key’s actions menu and select **Revoke**.
3. Select **Revoke key** to confirm.
Keys do not expire on a schedule; revoke or rotate them instead.
Keep keys out of clients you do not control
A key is a long-lived bearer secret. Send it only from servers you operate. If a key is exposed, rotate it with **Stop it now** or revoke it.
## Conversation ownership
[Section titled “Conversation ownership”](#conversation-ownership)
A conversation is bound to the API key that started it. Only that key can continue, retrieve, or delete it, and a different key in the same organization gets the same `404` as an unknown id. Plan for this when you have several keys: the key that starts a conversation is the key that must finish it.
## Authentication failures
[Section titled “Authentication failures”](#authentication-failures)
* **`401` `invalid_api_key`**: the header is missing or malformed, or the key is unknown or revoked. Check the key as in [Check a key](#check-a-key).
* **`403` `access_denied`**: the key is valid but is not entitled to what the request asked for. Ask an administrator of your organization.
* **`503` `auth_unavailable`**: the key could not be validated for a moment. It comes with `Retry-After: 1`, never as a `401`, and the SDKs retry `503` automatically, so a short outage never looks like a bad key.
# Clients and SDKs
> Which clients work against the helping.io API, and how to point one at it.
This API serves the Responses API, so an OpenAI Responses client works against it once you change the base URL and the key. If your app uses Chat Completions, migrate it to Responses first and it will work here unchanged.
## Clients that work
[Section titled “Clients that work”](#clients-that-work)
The API’s compatibility promise is that **the official OpenAI SDKs and Open Responses clients can call it and parse the result** without a custom integration: a non-streaming create, a next turn through `previous_response_id`, a stream read by the SDK’s own event reader, the function-calling loop, and the Conversations API all work the way they do against OpenAI. What is deliberately missing is listed under [Not implemented](/gateway/compatibility/#not-implemented), and nothing else is.
## Set up a client
[Section titled “Set up a client”](#set-up-a-client)
Point the client’s base URL at `https://api.helping.io/v1` and use your `sk_` key as the API key. The examples on these pages read the key from the `HELPINGIO_API_KEY` environment variable; see [API keys](/getting-started/authentication/) to create one.
* curl
Every request sends the key as a bearer token to the same base URL:
```bash
curl https://api.helping.io/v1/models \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
```
* Node
```bash
npm install openai
```
```js
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.helping.io/v1",
apiKey: process.env.HELPINGIO_API_KEY,
});
```
* Python
```bash
pip install openai
```
```python
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.helping.io/v1",
api_key=os.environ["HELPINGIO_API_KEY"],
)
```
The Node and Python examples on the other pages use a `client` built this way.
## Chat Completions
[Section titled “Chat Completions”](#chat-completions)
**A Chat Completions app needs migrating first.** This API speaks the Responses API, so `/v1/chat/completions` answers a `404` today. OpenAI’s [guide on moving to Responses](https://developers.openai.com/api/docs/guides/migrate-to-responses) covers the changes, and the same code works here once migrated.
```http
POST /v1/chat/completions
```
```json
{
"error": {
"code": "resource_not_found",
"message": "Unknown request URL: /v1/chat/completions. This surface serves /v1/responses, /v1/conversations and /v1/models (Open Responses). Chat Completions is not supported.",
"param": null,
"type": "not_found_error"
}
}
```
# Create a response
> POST /v1/responses creates one turn of a conversation. This page covers the request fields a turn needs, how input becomes a turn, and the turns that follow.
`POST /v1/responses` creates a response, which is one turn of a conversation. To continue that conversation, send the next turn with `previous_response_id` set to the id you got back.
## A first turn
[Section titled “A first turn”](#a-first-turn)
* curl
```bash
curl https://api.helping.io/v1/responses \
-H "Authorization: Bearer $HELPINGIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-sonnet-5",
"input": "Say hello in exactly 3 words."
}'
```
* Node
```js
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.helping.io/v1",
apiKey: process.env.HELPINGIO_API_KEY,
});
const response = await client.responses.create({
model: "anthropic/claude-sonnet-5",
input: "Say hello in exactly 3 words.",
});
console.log(response.output_text);
```
* Python
```python
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.helping.io/v1",
api_key=os.environ["HELPINGIO_API_KEY"],
)
response = client.responses.create(
model="anthropic/claude-sonnet-5",
input="Say hello in exactly 3 words.",
)
print(response.output_text)
```
The reply is a response object. The fields you will read most are `id`, `status`, `output`, and `usage`:
```json
{
"background": false,
"completed_at": 1767225600,
"conversation": null,
"created_at": 1767225600,
"error": null,
"frequency_penalty": 0.0,
"id": "resp_ZXhhbXBsZS9yZXNwL2ZpcnN0LXR1cm4AAAAAAAAAAAAAAAAAAAAAAA",
"incomplete_details": null,
"instructions": null,
"max_output_tokens": null,
"max_tool_calls": null,
"metadata": null,
"model": "anthropic/claude-sonnet-5",
"object": "response",
"output": [
{
"content": [
{
"annotations": [],
"logprobs": [],
"text": "Hello there, friend.",
"type": "output_text"
}
],
"id": "msg_00000000000000000000000000000001",
"role": "assistant",
"status": "completed",
"type": "message"
}
],
"parallel_tool_calls": true,
"presence_penalty": 0.0,
"previous_response_id": null,
"prompt_cache_key": null,
"reasoning": null,
"safety_identifier": null,
"service_tier": "default",
"status": "completed",
"store": true,
"temperature": 1.0,
"text": {
"format": {
"type": "text"
}
},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled",
"usage": {
"input_tokens": 100,
"input_tokens_details": {
"cache_write_tokens": 0,
"cached_tokens": 0
},
"output_tokens": 20,
"output_tokens_details": {
"reasoning_tokens": 0
},
"total_tokens": 120
}
}
```
The full object also echoes the sampling and tool fields you sent, or their defaults. Fields are `snake_case` throughout.
## Continuing a conversation
[Section titled “Continuing a conversation”](#continuing-a-conversation)
Send the next user turn with `previous_response_id`. The API keeps the conversation history, so you send only the new turn, never the history.
POST /v1/responses
```json
{
"input": "And again.",
"previous_response_id": "resp_ZXhhbXBsZS9yZXNwL2ZpcnN0LXR1cm4AAAAAAAAAAAAAAAAAAAAAAA"
}
```
Two rules apply to the turns after the first:
* **A conversation is bound to the key that started it.** A next turn sent with a different API key answers the same `404` as an unknown id, even inside the same organization. A rotated key keeps its conversations; revoking a key ends them for everyone.
* **`model` carries forward.** Omit it on a later turn and the conversation’s model is used. Supply it to switch models for that turn. See [Choose a model](/gateway/choose-a-model/).
## How input becomes a turn
[Section titled “How input becomes a turn”](#how-input-becomes-a-turn)
`input` carries exactly one thing: the current user turn.
| You send | What happens |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| A bare string | One user message |
| `message` items with role `user` | Their text joins the user message. Several user items concatenate in wire order, separated by a blank line |
| A `message` item with role `assistant` | `400`. Continue the conversation with `previous_response_id` instead |
| A `message` item with role `system` or `developer` | `400`. Use `instructions` for per-request steering |
| A `function_call_output` item | The tool-result path. See [Function calling](/gateway/function-calling/) |
| A `function_call` item with its matching `function_call_output` | Carried onto the new conversation as a real tool exchange. The call’s arguments and the result’s text become context; the tool’s name is not sent |
| A `function_call` with no matching output, or an output with no call | `400` naming which item and which half is missing |
| A `reasoning` item | Stored and readable, never sent to the model |
| An item-reference item | `400`. A first turn does not resolve item references, so send the item itself |
Multi-part user content keeps its text parts (`input_text`, `output_text`, `text`), joined with newlines. Every other part fails loudly rather than vanishing: `input_image`, `input_file`, `input_video`, `summary_text`, `reasoning_text` and `refusal` parts are each a `400` naming the part. Only text reaches the model, so send what it needs as an `input_text` part: a file’s text, or a description of an image.
A user item whose content is empty is skipped. An input array that yields no user text at all is a `400`. When that input holds a `function_call_output`, the message says so instead, because a new conversation has no tool call waiting for a result. Every refusal in this section sets `param` to `input`, and a refusal about one item names its index and type in the message.
Three more refusals apply to the whole request body:
* A string anywhere in the body that contains a NUL character (U+0000) is a `400` naming where it is. Remove or replace the character before sending.
* `conversation` and `previous_response_id` together are a `400`: each names the conversation a response continues, so send one of them.
* A malformed `previous_response_id` is a `400`, and one that names no response your key can see is a `404`.
Per-request steering with instructions
`instructions` is accepted as steering for that one request, and it is echoed back on the response. It is never inherited by a later turn: each request states its own instructions or has none.
## Request fields
[Section titled “Request fields”](#request-fields)
The fields a turn needs, and what each one does. Three things are true of this surface that are worth knowing before you read the tables: unknown fields are ignored rather than rejected, so a newer SDK never fails here; a field that parses but is not applied is listed as **accepted, ignored**, so nothing is silently half-supported; and the response object echoes back `model`, `previous_response_id`, `metadata`, `tools`, `tool_choice`, `truncation`, `instructions`, and the effective `max_output_tokens`. All of them survive a retrieve, with one difference worth knowing when you answer a tool call, described under [what a retrieve reports](/gateway/compatibility/#behavior-that-differs-from-what-you-might-expect). The request-only fields it does not echo report the specification’s default value regardless of what you sent, except the generation settings, which report the value the response was generated with.
### Required
[Section titled “Required”](#required)
| Field | Notes |
| ------- | -------------------------------------------------------- |
| `model` | Required on a first turn. Carries forward on later turns |
| `input` | Required on a new turn. String or item array, as above |
### Conversation
[Section titled “Conversation”](#conversation)
| Field | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `previous_response_id` | Continue the conversation that produced this response |
| `instructions` | Per-request steering, echoed, never inherited |
| `metadata` | Stored with the response and returned when you retrieve it. Up to 16 pairs of key and value, keys up to 64 characters, values strings up to 512 characters |
| `safety_identifier` | An opaque id for your end user, up to 64 characters. Sent where the model’s provider has a per-end-user field, as a keyed hash of your value, never the value itself, so one end user can be restricted without restricting your key. Echoed as you sent it |
| `user` | Deprecated in favor of `safety_identifier`. Used as your end user’s id when `safety_identifier` is absent, and ignored when both are sent. It does not group requests for caching, and the response does not echo its value |
### Tools
[Section titled “Tools”](#tools)
| Field | Notes |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools` | Your function tools. Echoed. See [Function calling](/gateway/function-calling/) |
| `tool_choice` | `none`, `auto`, `required`, `{"type": "function", "name": …}`, or an `allowed_tools` object. Echoed. A function choice needs a `name`; `allowed_tools` takes mode `auto` or `required` and a `name` on each entry; `required` needs at least one tool; and naming a tool that is not in your `tools` is a `400` naming the tool |
### Output
[Section titled “Output”](#output)
| Field | Notes |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stream` | `true` for server-sent events. Defaults to `false`. See [Streaming](/gateway/streaming/) |
| `stream_options` | `include_obfuscation`, on by default, pads each streamed delta event so its size does not track the length of the text in it. Set it to `false` to turn the padding off |
| `max_output_tokens` | Minimum 16. Echoed as the effective value, which can be lower than you asked for, and carried forward to a later turn that omits it |
| `truncation` | `disabled` (the default) refuses a turn that does not fit the model’s context window, with the code `context_length_exceeded`; `auto` drops the oldest turns so it fits. See [How `truncation` differs](/gateway/compatibility/#how-truncation-differs) |
### Everything else
[Section titled “Everything else”](#everything-else)
Generation settings, the fields that are accepted but advisory, and the fields that are refused are listed on [Compatibility and versioning](/gateway/compatibility/#not-implemented). The [API reference](/reference/api/) carries every field of the request body.
## Headers
[Section titled “Headers”](#headers)
| Header | Notes |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Authorization: Bearer sk_…` | Required. See [API keys](/getting-started/authentication/) |
| `Content-Type: application/json` | Recommended on `POST`. The body is read as JSON whatever this header says |
| `Idempotency-Key` | Optional, and the official SDKs do not send it on their own: pass it in the request options. Visible ASCII, at most 255 bytes. A retry with the same key and body returns the first response, so the model produces output and bills once; the same key with a different body is a `409`. Remembered for 24 hours. See [Idempotency](/gateway/errors/#idempotency) |
## Waiting for the result
[Section titled “Waiting for the result”](#waiting-for-the-result)
A non-streaming request waits up to 90 seconds. A response that is not finished by then comes back in progress: the same `id`, a `status` of `queued` or `in_progress`, an empty `output`, and HTTP `200`. It is not an error, and it is not the end of the work. Poll `GET /v1/responses/{response_id}` for the result.
```json
{
"background": false,
"completed_at": null,
"conversation": null,
"created_at": 1767225600,
"error": null,
"frequency_penalty": 0.0,
"id": "resp_ZXhhbXBsZS9yZXNwL2luLXByb2dyZXNzLWF0LXRoZS13YWl0LWJvdQ",
"incomplete_details": null,
"instructions": null,
"max_output_tokens": 1024,
"max_tool_calls": null,
"metadata": null,
"model": "anthropic/claude-sonnet-5",
"object": "response",
"output": [],
"parallel_tool_calls": true,
"presence_penalty": 0.0,
"previous_response_id": null,
"prompt_cache_key": null,
"reasoning": null,
"safety_identifier": null,
"service_tier": "default",
"status": "in_progress",
"store": true,
"temperature": 1.0,
"text": {
"format": {
"type": "text"
}
},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled"
}
```
For work that takes longer than a few seconds, or when you want tokens as they arrive, use [streaming](/gateway/streaming/), which is bounded at 300 seconds instead.
Closing a non-streaming request cancels the response
If your client disconnects while a non-streaming request is waiting, the response is canceled and you are billed only for what had already been produced. Streaming is the other way around: closing a stream does not cancel the response, and you can still retrieve the result by id.
# Errors
> The error envelope, every status and code the helping.io API returns, what each one asks you to do, and the timeouts and retry rules to design for.
Every error on `/v1` is the standard Responses API envelope, including the ones raised before your request reaches the endpoint, such as a bad key.
```json
{
"error": {
"message": "missing or malformed Authorization header",
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
}
}
```
* `message` is human-readable and safe to log.
* `type` follows from the HTTP status, so SDK retry logic can branch on it. The one exception is `insufficient_quota`: a `429` that carries type `insufficient_quota`, as OpenAI’s own quota refusal does, so a client that handles that refusal from OpenAI handles this one.
* `code` is the specific condition, always lowercase, and is the field to switch on in your own code.
* `param` names the request field a refusal is about, when it is about one: `model`, `input`, `metadata`, `tool_choice`, `max_output_tokens` and the rest are reported as you sent them, so your error handler can branch on the field instead of reading the message. It is `null` for everything else, including a body that will not parse, a bad key, and every rate limit. Read it when it is there; do not require it.
## Status to type
[Section titled “Status to type”](#status-to-type)
| HTTP status | `type` |
| ----------- | ----------------------- |
| `400` | `invalid_request_error` |
| `401` | `invalid_request_error` |
| `402` | `invalid_request_error` |
| `403` | `permission_error` |
| `404` | `not_found_error` |
| `409` | `invalid_request_error` |
| `429` | `insufficient_quota` |
| `429` | `rate_limit_error` |
| `500` | `api_error` |
| `502` | `api_error` |
| `503` | `overloaded_error` |
| `504` | `api_error` |
Any other `4xx` carries `invalid_request_error`.
## Codes
[Section titled “Codes”](#codes)
### Authentication, authorization, and billing
[Section titled “Authentication, authorization, and billing”](#authentication-authorization-and-billing)
| Status | `code` | `Retry-After` | Meaning |
| ------ | ------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `401` | `invalid_api_key` | No | The Authorization header is missing, malformed, or names a key that is not usable; check the key. |
| `403` | `access_denied` | No | The key is valid but is not entitled to what the request asked for; ask your administrator. |
| `429` | `insufficient_quota` | No | The organization has no active plan, or no credit balance to pay for the request; the message says which. |
| `402` | `payment_method_required` | No | The organization has no payment method on file, so no request can be served; add one in the billing settings. |
| `503` | `auth_unavailable` | Yes | Your credentials could not be checked this instant, which is not a verdict on the key; retry after the Retry-After header. |
`insufficient_quota` means your organization cannot pay for the request: it has no active plan, or it has no credit balance. The message says which. It is not a rate limit, and retrying does not change the answer.
### Request validation
[Section titled “Request validation”](#request-validation)
Nothing is created and nothing is charged when one of these comes back.
| Status | `code` | `param` | Meaning |
| ------ | ------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400` | `invalid_input` | The offending field | The request cannot be honored as written; the message names the field and the rule it broke. |
| `400` | `context_length_exceeded` | Always null | The conversation plus this turn's input is larger than the model's context window; start a new conversation, send less, or set truncation to auto to let the oldest turns be dropped. |
| `400` | `model_policy_violation` | The offending field | Your organization's policy does not allow the model this request selected; the message names the model and the rule that refused it, and param is \`model\`. |
| `404` | `resource_not_found` | Always null | The path or the previous response named by this request is not available to you. |
### Conflicts
[Section titled “Conflicts”](#conflicts)
| Status | `code` | Meaning |
| ------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `409` | `idempotency_conflict` | This Idempotency-Key was already used within the last 24 hours, and what it made cannot be returned for this request: the body differs, or this API key cannot read it. Nothing was created. |
| `409` | `idempotency_in_progress` | The first request with this Idempotency-Key is still being processed. Nothing was created; send the request again shortly. |
See [Idempotency](#idempotency).
### Capacity and limits
[Section titled “Capacity and limits”](#capacity-and-limits)
Entries marked `Retry-After` carry that header; honor it. See [Rate-limit headers](/billing/rate-limits/#rate-limit-headers) for the headers that let you avoid most of them.
| Status | `code` | `Retry-After` | Meaning |
| ------ | ----------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `429` | `rate_limit_exceeded` | Yes | This request exceeded a rate limit that applies to you; retry after the Retry-After header. |
| `429` | `stream_limit_exceeded` | Yes | You are already holding the maximum number of open streams; nothing was started, so retry once one ends. |
| `429` | `scope_budget_exceeded` | No | A spend budget set for your organization, a member, or an API key is exhausted; the message states which. |
| `429` | `insufficient_credits` | No | The organization's credit balance is exhausted; top up in the billing settings. |
| `503` | `gateway_overloaded` | Yes | The API could not accept this request and nothing was created; retry after the Retry-After header, and retrying with the same Idempotency-Key still deduplicates. |
| `503` | `database_unavailable` | Yes | The request could not be served this instant and nothing was created; retry after the Retry-After header. |
### Model provider failures
[Section titled “Model provider failures”](#model-provider-failures)
| Status | `code` | Meaning |
| ------ | ----------------------- | ---------------------------------------------------------------------------------------------- |
| `429` | `provider_rate_limited` | The model provider rate limited this request; retry, honoring Retry-After when one is present. |
| `502` | `provider_overloaded` | The model provider reported that it is overloaded; retry shortly. |
| `502` | `provider_unavailable` | The model provider did not complete the request; retry shortly. |
A provider that rate limits your request is refused with `429`, carrying the envelope the table above describes:
Response
```json
{
"error": {
"code": "provider_rate_limited",
"message": "The upstream model provider rate limited this request; retry later.",
"param": null,
"type": "rate_limit_error"
}
}
```
Non-transient provider failures do not use the error envelope. They arrive as a `200` whose response object has `status: "failed"` and an `error` with a `code` and a message. See [Failed responses](#failed-responses).
### Server faults
[Section titled “Server faults”](#server-faults)
| Status | `code` | Meaning |
| ------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `504` | `request_timeout` | This request's wait window ended before a result was available and no response could be read back for it; quote the x-request-id response header when reporting it. |
| `500` | `internal_server_error` | The request could not be completed; quote the x-request-id response header when reporting it. |
| `500` | `database_error` | The request could not be completed; quote the x-request-id response header when reporting it. |
| `500` | `item_unreadable` | The item is stored but could not be read, so it cannot be returned. A list of items names it in helpingio:unreadable and returns the others. |
`item_unreadable` answers a request for one item that is stored but could not be read. A list of items does not fail because of it: the list names the item in `helpingio:unreadable` and returns the others.
## Failed responses
[Section titled “Failed responses”](#failed-responses)
A response that fails after it has started is not an HTTP error. It is a response object with `status: "failed"` and a populated `error`:
```json
{
"id": "resp_…",
"object": "response",
"status": "failed",
"output": [],
"error": {
"code": "provider_client_error",
"message": "The upstream model provider rejected the request."
},
"usage": {
"input_tokens": 100,
"input_tokens_details": { "cached_tokens": 0, "cache_write_tokens": 0 },
"output_tokens": 0,
"output_tokens_details": { "reasoning_tokens": 0 },
"total_tokens": 100
}
}
```
The same shape arrives inside a streaming `response.failed` event. `usage`, when present, shows what the failed response consumed; read it with a presence check.
`error.code` on this channel is a closed set, and a failure this surface does not name separately arrives as `run_failed`. Switch on the code; the message is written for a person to read and may be reworded.
| `code` | Meaning |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `budget_exceeded` | The spending limit for a single response was reached. The message states what was spent against the limit, in credits. |
| `canceled` | The request was canceled before it finished. |
| `end_user_blocked` | The model provider refuses requests for the end user this request was sent for, named by its safety\_identifier or user. Sending it again will not succeed for that end user. |
| `insufficient_credits` | The organization ran out of credits while the request was running. Add credits and send it again. The message states the balance, in credits. |
| `org_daily_budget_exceeded` | The organization's daily spending limit was reached. The message states what was spent against the limit, in credits. |
| `provider_client_error` | The upstream model provider rejected the request. The request reached the provider and came back refused. |
| `provider_error` | The upstream model provider returned an unexpected error. |
| `provider_overloaded` | The upstream model provider is overloaded. Retry later. |
| `provider_rate_limited` | The upstream model provider rate limited the request. Retry later. |
| `provider_unavailable` | The upstream model provider failed to complete the request. Retry later. |
| `run_failed` | The request did not complete. Send it again, and contact support with the response id if it keeps failing. |
| `scope_budget_exceeded` | A spending limit set for this period was reached. The message states what was spent against the limit, in credits. |
Five of these codes are in the HTTP tables above as well, because where a failure reaches you depends on how you asked:
* A non-streaming `POST /v1/responses` answers an upstream provider failure with an HTTP error, so `provider_rate_limited`, `provider_overloaded` and `provider_unavailable` arrive there with a status.
* A streaming request answers `200` before it can know, so the same failure arrives in the response object instead. So does a `GET /v1/responses/{response_id}`, which answers what happened rather than refusing.
* `scope_budget_exceeded` is an HTTP refusal when the limit is already spent as the request arrives, and a failed response when it runs out while the request is running.
* Out of credits as the request arrives is the HTTP refusal `insufficient_quota`. `insufficient_credits` is the later, rarer case: an HTTP refusal when the request was accepted but the balance could not pay to start it, and a failed response when credits run out while the request is running. Handle both codes the same way.
A spending failure in a failed response states your own figures in credits, to one decimal place: what was spent against the limit, or the balance. Switch on the code; the figures are for the person reading the message.
## Errors during a stream
[Section titled “Errors during a stream”](#errors-during-a-stream)
Once a stream is open the HTTP status has already been sent, so every later failure is in-band:
* `response.failed` carries the failed response object above.
* `response.incomplete` means the response stopped early, for example at `max_output_tokens`. Read `incomplete_details.reason`.
* An `error` event carries a failure that belongs to neither: the stream itself could not deliver a result.
The codes an `error` event can name:
| `code` | Meaning |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `response_unresolved` | The stream ended before a result was ready. The response may still finish: retrieve it by the id response.created carried. Retrying without the same Idempotency-Key starts a second response. |
## Timeouts and retries
[Section titled “Timeouts and retries”](#timeouts-and-retries)
The rate limits, and the headers that report them, are on [Rate limits](/billing/rate-limits/).
### Timeouts
[Section titled “Timeouts”](#timeouts)
| Bound | Value | What happens |
| ------------------ | ---------------- | ------------------------------------------------------------------------ |
| Non-streaming wait | 90 seconds | The in-progress response object is returned with `200`. Poll by id |
| Stream ceiling | 300 seconds | The stream closes with its terminal event. Retrieve by id |
| Stream keep-alive | every 15 seconds | An SSE comment frame keeps intermediaries from timing out a quiet stream |
### Idempotency
[Section titled “Idempotency”](#idempotency)
`POST /v1/responses` honors the `Idempotency-Key` header, and so do the requests that create a conversation, update one and add items to one. The official SDKs do not send it on their own, so pass it yourself in the request options and reuse it when you retry:
* A retried request with the same key and the same body returns the first request’s response, so the model produces output and bills once. That includes a request that sends `function_call_output` items.
* The same key with a different body is a `409` with the code `idempotency_conflict`, and nothing is created.
* A retry that arrives while the first request is still being processed is a `409` with the code `idempotency_in_progress`. Nothing is created; send it again shortly.
* A request that is refused, for example with a `400`, does not use up its key, so you can correct it and send it again with the same key.
* A key is remembered for 24 hours. After that, the same key starts a new request.
* Keys belong to your organization, and each endpoint keeps its own keys. A retry from a different API key than the first request’s is a `409` with the code `idempotency_conflict` when that key cannot read what the first request made. On a conversation’s own endpoints, the same key and body sent to a different conversation is a `409` with the code `idempotency_conflict`.
* Keys are printable ASCII, spaces included, at most 255 bytes. A longer key, or one with any other character, is a `400`. Empty keys are ignored.
* Without a key, a retry after a delivery failure creates a second response and a second charge.
### Backing off well
[Section titled “Backing off well”](#backing-off-well)
* Honor `Retry-After` on every `429` and `503`. Use exponential backoff with jitter when the header is absent.
* Watch the remaining-requests header above and slow down before it reaches zero.
* Send an `Idempotency-Key` on every create so retries are safe.
* Prefer streaming for long work. A stream is bounded at 300 seconds, a non-streaming wait at 90.
Request ids
Every response carries an `x-request-id` header. Quote it when you contact [support](mailto:support@helping.io) about a `5xx`.
# 404
> That page does not exist. The overview and the quickstart are one click away.
# Credits and billing
> What a request costs in credits, how to buy credits and see your balance and spend, and what happens when an organization cannot pay for a request.
Requests are paid for in credits, and one credit is one US dollar. A request is charged the model provider’s list price for the tokens it used, and a request with no credits behind it is refused before it runs. Credits are a balance on your plan: an organization needs an active plan, a card on file and credits to make requests. Pricing is on [helping.io](https://helping.io/#pricing).
## What a request costs
[Section titled “What a request costs”](#what-a-request-costs)
A response’s `usage` holds the tokens it was charged for. Multiply each count by the model’s price per million tokens, listed on [helping.io/models](https://helping.io/models/).
For example, `anthropic/claude-sonnet-5` is listed at $2.00 per million input tokens and $10.00 per million output tokens. A response whose `usage` reads:
```json
{
"input_tokens": 1200,
"input_tokens_details": { "cache_write_tokens": 0, "cached_tokens": 0 },
"output_tokens": 300,
"output_tokens_details": { "reasoning_tokens": 0 },
"total_tokens": 1500
}
```
costs:
| Tokens | Price per million | Credits |
| ----------- | ----------------- | ---------- |
| 1,200 input | $2.00 | 0.0024 |
| 300 output | $10.00 | 0.0030 |
| **Total** | | **0.0054** |
Tokens read from or written to a prompt cache (`cached_tokens` and `cache_write_tokens`) are charged at the provider’s cache prices instead of the input price.
`usage` is kept on the stored response, so a client whose connection dropped can still find out what it was charged: [Retrieve a response](/gateway/retrieve-and-delete/#retrieve-a-response).
## Buy credits
[Section titled “Buy credits”](#buy-credits)
Only the organization’s owner can buy credits. Admins can see the balance but not buy.
1. In the app, open **Settings → Billing**.
2. Under **Add credits**, pick an amount or enter a **Custom amount**. The card shows the subtotal, the **Service fee** and the **Total** you will be charged.
3. Select **Buy credits** and pay on the checkout page.
You can also buy credits when you start your plan, by leaving **Add credits now** checked. **Add credits** appears only once the organization has a plan, because credits cannot be spent without one.
## See your balance and spend
[Section titled “See your balance and spend”](#see-your-balance-and-spend)
* **Balance:** **Settings → Billing**, under **Credit balance**. Owners and admins can see it. **Purchase history** on the same page lists each purchase with its credits, service fee and the balance after it.
* **Spend:** **Settings → Usage** lists each charge with its date, model, tokens, credits and the balance after it.
## When a request is refused
[Section titled “When a request is refused”](#when-a-request-is-refused)
| Status | `code` | Meaning |
| ------ | ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `402` | `payment_method_required` | The organization has no payment method on file, so no request can be served; add one in the billing settings. |
| `429` | `insufficient_quota` | The organization has no active plan, or no credit balance to pay for the request; the message says which. |
* **`402` `payment_method_required`**: the organization has no card on file. The owner adds one in **Settings → Billing**: **Start a plan** if there is no plan yet, otherwise **Manage billing**.
* **`429` `insufficient_quota`**: the organization has no active plan or no credit balance, and the message says which. The owner starts the plan or buys credits as above. It is not a rate limit, and retrying does not change the answer.
Neither refusal creates or charges anything. Running out while a request is already running is `insufficient_credits`; see [Errors](/gateway/errors/#failed-responses).
A budget an administrator set is a different refusal: [Spend controls](/billing/spend-controls/).
# Rate limits
> The request rate limit an organization has, the headers that report it, the limit on open streams, and the separate allowances for retrieving, canceling and deleting.
## Request rate limit
[Section titled “Request rate limit”](#request-rate-limit)
Each organization is limited to 60 requests per minute. That is the current default and it may change. The live values are in the rate-limit headers on each response, so a client can slow down before it is refused. To raise your limit, contact . Refusals are `429` with code `rate_limit_exceeded`.
## Rate-limit headers
[Section titled “Rate-limit headers”](#rate-limit-headers)
Every rate-limited request carries the standard headers for the allowance that governs it, so a client can slow down before it is refused rather than after. They come on a successful response and on a `429`; any other error carries none. Listing models is not rate limited and carries none:
| Header | Meaning |
| -------------------------------- | ----------------------------------------------------------------------------- |
| `x-ratelimit-limit-requests` | The request allowance |
| `x-ratelimit-remaining-requests` | Requests left in the allowance |
| `x-ratelimit-reset-requests` | Time until the allowance is full again, as a duration, for example 1s or 6m0s |
Where a tokens-per-minute cap applies to your key, the token family comes alongside them:
| Header | Meaning |
| ------------------------------ | ----------------------------------------------------------------------- |
| `x-ratelimit-limit-tokens` | The token allowance, sent only when a token cap applies |
| `x-ratelimit-remaining-tokens` | Tokens left in the current window |
| `x-ratelimit-reset-tokens` | Time until the token window rolls over, as a duration, for example 6m0s |
A refused request answers `429` with code `rate_limit_exceeded` and `Retry-After` set to the seconds until the allowance refreshes. Nothing is created and nothing is charged.
## Open streams
[Section titled “Open streams”](#open-streams)
Each organization can hold a limited number of streamed responses open at once. Non-streaming requests do not count against it. A streaming request past the limit is refused before anything starts, so nothing is created and nothing is charged; the message states the limit, and `Retry-After` says when to try again. Waiting for one of your open streams to end also frees a place.
| Status | `code` | `Retry-After` | Meaning |
| ------ | ----------------------- | ------------- | -------------------------------------------------------------------------------------------------------- |
| `429` | `stream_limit_exceeded` | Yes | You are already holding the maximum number of open streams; nothing was started, so retry once one ends. |
## Retrieving, canceling and deleting
[Section titled “Retrieving, canceling and deleting”](#retrieving-canceling-and-deleting)
Two more allowances apply per organization, separately from the ones above:
| Requests | At once | Then |
| ------------------------------------------------------------------------- | ------------ | --------------------- |
| Retrieving a response, and every Conversations API request except deletes | 300 requests | 5 requests per second |
| Deletes of responses, conversations and items, and cancels, together | 100 requests | 2 requests per second |
A request under one of these allowances reports it in the same headers: its size as the limit, what is left, and the time until it is full again. Past either, the answer is the same `429` with `Retry-After`.
# Spend controls
> The budgets an organization administrator can set on what its keys spend, and how a budget shows up on the wire.
Organization administrators govern what their keys can spend. A budget can be set for the whole organization, a member, or one API key, and where several apply the strictest one wins. This page is written for the developer holding the key: what a budget does to your requests, and how you recognize it when it refuses one.
## Budgets
[Section titled “Budgets”](#budgets)
A budget caps spend for a scope over a period, measured in credits against the real cost of the model calls made. One credit is one US dollar. A budget set to **Block requests** refuses requests once it is exhausted; a budget set to **Warn only** only notifies.
A refused request is a `429` with code `scope_budget_exceeded`, the message states the budget that was hit, and nothing is created or charged. A `429` with code `insufficient_quota` is a different refusal, covered on [Credits and billing](/billing/credits/).
# Overview
> The helping.io API is an Open Responses API. Any Responses API client can call it.
The helping.io API is the HTTP surface for calling helping.io from your own code. It implements the [Open Responses](https://github.com/openresponses/openresponses) specification; the official OpenAI SDKs and any Open Responses client are clients that work with it, without a custom integration.
Your request carries the current user turn, the model to run it on, and, optionally, your own function tools.
## Base URL
[Section titled “Base URL”](#base-url)
```text
https://api.helping.io/v1
```
Point your SDK’s base URL at the `/v1` prefix, not at the host on its own.
## Endpoints
[Section titled “Endpoints”](#endpoints)
These are the routes the API exposes. Every other path under `/v1` answers a `404` in the standard error envelope, including `/v1/chat/completions`: this API speaks the Responses API, and OpenAI’s [guide on moving to Responses](https://developers.openai.com/api/docs/guides/migrate-to-responses) covers the change if your app still uses Chat Completions.
| Route | What it does |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `GET /v1/models` | List the model catalog, as canonical `provider/model` ids |
| `POST /v1/responses` | Create a response: a new turn, or a further turn through `previous_response_id` |
| `GET /v1/responses/{response_id}` | Retrieve a stored response, including its usage |
| `DELETE /v1/responses/{response_id}` | Delete one stored response |
| `POST /v1/responses/{response_id}/cancel` | Cancel a response your key created |
| `POST /v1/conversations` | Create a conversation to hold a history by id |
| `GET`, `POST`, `DELETE /v1/conversations/{conversation_id}` | Read, label, or delete a conversation |
| `GET`, `POST /v1/conversations/{conversation_id}/items` | List a conversation’s items, or add to them |
| `GET`, `DELETE /v1/conversations/{conversation_id}/items/{item_id}` | Read or remove one item |
## How it fits together
[Section titled “How it fits together”](#how-it-fits-together)
Conversations ride on previous\_response\_id
Each response is one turn. Pass the previous response’s id to continue the same conversation. The API keeps the history, so you never resend it.
Your tools are yours to execute
Function tools you define in a request come back as `function_call` items. You run them and post the result. Standard SDK tool loops work unchanged.
Extensions carry the helpingio: prefix
Everything beyond the Open Responses specification is a namespaced extension field. Clients that do not know the prefix strip it and still parse.
Which specification, and what that buys you
The API implements the Open Responses specification; the OpenAI SDKs are clients that work with it. Unknown request fields never fail a request, so a newer SDK than this surface still talks to it. See [Compatibility](/gateway/compatibility/) for what is and is not implemented.
## Read next
[Section titled “Read next”](#read-next)
[Quickstart](/getting-started/quickstart/)Create a key, send your first request, and read the reply.
[Create a response](/gateway/create-a-response/)Every request field, how input becomes a turn, and how the next turn continues the conversation.
[Streaming](/gateway/streaming/)Server-sent events, the event vocabulary, and the stream's guarantees.
[Conversations](/gateway/conversations/)Create a conversation, label it, read and edit its items, and hand its id to a response.
[Errors](/gateway/errors/)The error envelope, every code you can receive, and what to do about each.
# Choose a model
> How a request names its model, and how to list the models you can use.
`model` names a model from the catalog. The canonical form is `provider/model`, and `GET /v1/models` lists every id you can use. The catalog is also listed on [helping.io/models](https://helping.io/models/).
```bash
curl https://api.helping.io/v1/models \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
```
```json
{
"data": [
{
"created": 1767225600,
"id": "anthropic/claude-fable-5",
"object": "model",
"owned_by": "anthropic"
},
{
"created": 1767225600,
"id": "anthropic/claude-fable-5-1",
"object": "model",
"owned_by": "anthropic"
},
{
"created": 1767225600,
"id": "anthropic/claude-opus-4-8",
"object": "model",
"owned_by": "anthropic"
},
{
"created": 1767225600,
"id": "anthropic/claude-opus-5-5",
"object": "model",
"owned_by": "anthropic"
},
{
"created": 1767225600,
"id": "anthropic/claude-sonnet-5",
"object": "model",
"owned_by": "anthropic"
},
{
"created": 1767225600,
"id": "anthropic/claude-sonnet-5-5",
"object": "model",
"owned_by": "anthropic"
},
{
"created": 1767225600,
"id": "google/gemini-3.1-flash-lite",
"object": "model",
"owned_by": "google"
},
{
"created": 1767225600,
"id": "google/gemini-3.5-flash",
"object": "model",
"owned_by": "google"
},
{
"created": 1767225600,
"id": "google/gemini-3.5-flash-lite",
"object": "model",
"owned_by": "google"
},
{
"created": 1767225600,
"id": "google/gemini-3.6-flash",
"object": "model",
"owned_by": "google"
},
{
"created": 1767225600,
"id": "google/gemini-3.7-flash",
"object": "model",
"owned_by": "google"
},
{
"created": 1767225600,
"id": "google/gemini-3.8-flash",
"object": "model",
"owned_by": "google"
},
{
"created": 1767225600,
"id": "openai/gpt-5.4-mini",
"object": "model",
"owned_by": "openai"
}
],
"object": "list"
}
```
A bare name resolves when exactly one active model carries it: `claude-sonnet-5` reaches the provider-qualified id `anthropic/claude-sonnet-5`. If more than one provider serves that name the request is a `400` telling you to use the `provider/model` form. `model` is required on a first turn; a missing value is a `400` with the message `model is required`.
Which settings each model accepts is on [Compatibility and versioning](/gateway/compatibility/#what-each-model-accepts).
# Conversations
> Create a conversation, label it with metadata, read and edit the items in it, and hand its id to a response.
A conversation is the history the API keeps for you. Every response belongs to one, so a later turn sends only the new input rather than replaying what came before.
You do not have to create one. A plain `POST /v1/responses` keeps your history for you, and `previous_response_id` continues it. Create one yourself when you want to hold its id, label it, seed it with turns that happened somewhere else, or read its history back.
The Node and Python examples use a `client` [set up for this API](/getting-started/clients/#set-up-a-client).
## Create a conversation
[Section titled “Create a conversation”](#create-a-conversation)
* curl
```bash
curl https://api.helping.io/v1/conversations \
-H "Authorization: Bearer $HELPINGIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"metadata": {"customer": "acme"}}'
```
* Node
```js
const conversation = await client.conversations.create({
metadata: { customer: "acme" },
});
console.log(conversation.id);
```
* Python
```python
conversation = client.conversations.create(metadata={"customer": "acme"})
print(conversation.id)
```
```json
{
"id": "conv_…",
"object": "conversation",
"created_at": 1758330000,
"metadata": { "customer": "acme" }
}
```
`created_at` is in seconds since the Unix epoch. `metadata` is always present; a conversation with no labels reports `{}`.
You can seed the conversation at the same time by passing `items`, up to 20 of them. See [Items](#items).
### Retrying safely
[Section titled “Retrying safely”](#retrying-safely)
Creating a conversation, updating its metadata and adding items all honor the `Idempotency-Key` header, with the same rules as creating a response. A retry with the same key and the same body returns the first answer and stores nothing new, so seeded items never appear twice. The same key with a different body is a `409`. Each of the three keeps its own keys, and a key is remembered for 24 hours. See [Idempotency](/gateway/errors/#idempotency).
A retry answers with the conversation as it is now, not a copy of the first answer: if its metadata changed since, the retry shows the change. A retry of adding items answers with the items that request added, leaving out any you have removed since. A retry of creating or updating a conversation that it cannot read, because the conversation has been deleted since or because the retry came from a different API key, is a `409` with the code `idempotency_conflict`. A retry of adding items to a conversation deleted since is a `404`, the answer any request naming that conversation gets.
## Use a conversation on a response
[Section titled “Use a conversation on a response”](#use-a-conversation-on-a-response)
Pass its id as `conversation`. The conversation’s items are the context for the response, and the response’s input and output are added to it.
```bash
curl https://api.helping.io/v1/responses \
-H "Authorization: Bearer $HELPINGIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-sonnet-5",
"input": "What did I ask you first?",
"conversation": "conv_…"
}'
```
`conversation` also accepts the object form, `{"id": "conv_…"}`. The response comes back carrying `"conversation": {"id": "conv_…"}`, so you can read the id off the response instead of tracking it yourself. A request that names no conversation gets `"conversation": null`, matching the OpenAI API.
One conversation per request
`conversation` and `previous_response_id` both name the conversation a response continues, so sending both is a `400`. Use either one.
A `conversation` your key did not create, one that does not exist, and one you have deleted are all the same `404`. A conversation is **never created implicitly by naming it**: a stale id is an error rather than a fresh, empty history.
The first response on a conversation you created needs a `model`, exactly like any first turn. After that the conversation keeps its model, and later turns may omit it.
## Read, label and delete
[Section titled “Read, label and delete”](#read-label-and-delete)
* curl
```bash
# read
curl https://api.helping.io/v1/conversations/conv_… \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
# replace the labels
curl https://api.helping.io/v1/conversations/conv_… \
-H "Authorization: Bearer $HELPINGIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"metadata": {"customer": "globex"}}'
# delete
curl -X DELETE https://api.helping.io/v1/conversations/conv_… \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
```
* Node
```js
await client.conversations.retrieve("conv_…");
await client.conversations.update("conv_…", {
metadata: { customer: "globex" },
});
await client.conversations.delete("conv_…");
```
* Python
```python
client.conversations.retrieve("conv_…")
client.conversations.update("conv_…", metadata={"customer": "globex"})
client.conversations.delete("conv_…")
```
An update **replaces** `metadata` rather than merging into it. Send the whole map, or `{"metadata": {}}` to clear it. `metadata` is required: an update without it is a `400`.
A delete answers `{"id": "conv_…", "object": "conversation.deleted", "deleted": true}`. The conversation and its history stop being readable and it can no longer be continued. Deleting the same conversation twice is not an error.
Its content (messages, the model requests and responses, tool inputs and outputs, stored outputs and attached files) is erased 30 days after the delete. Deleting a conversation does not remove what you were charged or the record of the calls that produced it. Your spend history and invoices are unaffected.
### Metadata limits
[Section titled “Metadata limits”](#metadata-limits)
| Rule | Limit |
| ---------------------- | -------------- |
| Pairs per conversation | 16 |
| Key length | 64 characters |
| Value length | 512 characters |
| Value type | string |
Anything outside these is a `400` naming the rule it broke. Metadata is stored for you to read back; no route filters or searches on it, and it is never sent to a model.
## Items
[Section titled “Items”](#items)
The items of a conversation are its turns: the messages, the tool calls, the tool results and the reasoning that make up its history.
### List items
[Section titled “List items”](#list-items)
```bash
curl "https://api.helping.io/v1/conversations/conv_…/items?limit=20" \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
```
```json
{
"object": "list",
"data": [{ "type": "message", "id": "msg_…", "role": "user", "status": "completed", "content": [{ "type": "input_text", "text": "hello" }] }],
"has_more": false,
"first_id": "msg_…",
"last_id": "msg_…"
}
```
| Parameter | Meaning |
| --------- | ------------------------------------------------------------ |
| `limit` | Items per page, 1 to 500. Defaults to 20 |
| `order` | `asc` for oldest first, the default, or `desc` |
| `after` | Continue after this item. Pass the previous page’s `last_id` |
| `include` | `reasoning.encrypted_content` to include reasoning payloads |
`include` is accepted repeated (`include=a&include=b`), bracketed (`include[]=a`), or comma separated, and it works the same way on the single-item read and when adding items. A value this API does not define is a `400` naming it, so a request that asks for something unavailable is told rather than answered as though it had asked for nothing.
An item that is stored but could not be read is left out of `data` and named in `helpingio:unreadable` by its `id`. The rest of the page is returned, it still counts toward `has_more` and can be the page’s `last_id`, and retrieving it returns the `item_unreadable` error.
`after` is exclusive, so no item appears on two pages. An `after` that names no item in this conversation is a `400` rather than an empty page, so a client never mistakes a bad cursor for the end of the history.
### Add items
[Section titled “Add items”](#add-items)
Up to 20 per call. This is how you hand the API a conversation that started somewhere else.
Every item you add is stored whole and reads back exactly as you sent it. **What the next response’s model sees is less than that**, and how much less depends on the kind, so the table below says it per kind rather than promising one rule.
```bash
curl https://api.helping.io/v1/conversations/conv_…/items \
-H "Authorization: Bearer $HELPINGIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{"type": "message", "role": "user", "content": "What is the capital of France?"},
{"type": "message", "role": "assistant", "content": "Paris."}
]
}'
```
The answer is the items you just added, in an item list.
Unlike the `input` of a response, items here may carry the `assistant`, `system` and `developer` roles, and may be `function_call`, `function_call_output` or `reasoning` items. An item reference is a `400`, here and when you create the conversation with `items`: send the item itself.
#### What the model sees, per kind
[Section titled “What the model sees, per kind”](#what-the-model-sees-per-kind)
| Item you add | What the next response’s model sees |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `message` with role `user` or `assistant` | The text, as that role |
| `message` with role `system` or `developer` | The text, but as a **user** turn rather than as an instruction. To steer the model, send `instructions` on the response |
| `function_call` paired with its `function_call_output` | A real tool call and its result, with the tool’s `name` and the correlation between them intact |
| `function_call` with no matching output, or an output with no call | Nothing. A call with no result asks the model to answer something the history does not contain, and a result with no call refers to nothing |
| `reasoning` | Nothing. It is stored and reads back in full, and no part of it is sent. If the model needs what the reasoning concluded, add that as an `assistant` message |
Apart from a paired tool exchange, the **text** of an item is what reaches the model. An item carrying an image, file or video part is refused with a `400` naming it, because media input is not supported.
An imported tool exchange is history, not a live call
A `function_call` and its `function_call_output` are replayed to the model as a tool call and its result, so a model reading the conversation sees the exchange for what it was. It is still HISTORY: the call already ran wherever you ran it, so it cannot be answered again, and posting a result for one is refused. To continue a tool-calling loop, drive it with `previous_response_id` and `function_call_output` on a response, which is the path that expects a reply.
With reasoning on, the exchange may be shown as text
With reasoning on, the model has a stricter requirement: an exchange it is shown must carry the reasoning that produced it, and a replayed exchange carries reasoning from wherever it originally ran, which cannot be verified here. In those turns the exchange is described to the model in words instead, naming the tool, what it was called with, and what came back. The exchange is still there and still accurate; it is simply not offered as a call the model could make again.
Why reasoning is not sent
Reasoning carries a payload that only the model that produced it can verify, and the verification is tied to whoever issued it. We do not record an issuer for a payload you supply, so we cannot tell a model’s own reasoning from another’s, and sending an unverifiable one is refused by the model rather than ignored. So your `reasoning` items are stored and read back exactly as you sent them, and are left out of what the model sees.
### Read and remove one item
[Section titled “Read and remove one item”](#read-and-remove-one-item)
```bash
curl https://api.helping.io/v1/conversations/conv_…/items/msg_… \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
curl -X DELETE https://api.helping.io/v1/conversations/conv_…/items/msg_… \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
```
A delete answers with the **conversation**, not a deleted-object shape.
Only items you added can be removed
An item a response produced is part of that response’s record, and removing it would leave the history with a gap in it. Deleting such an item is a `400`. To remove a conversation’s whole history, delete the conversation.
### What the item list does not show
[Section titled “What the item list does not show”](#what-the-item-list-does-not-show)
Two kinds of thing in a conversation are absent from the list:
* **A tool call that failed on our side.** The response that made the call reports the failure as a `function_call_output` carrying `helpingio:error` ([When this API refuses a call](/gateway/function-calling/#when-this-api-refuses-a-call)), but the conversation’s item list does not show it.
* **Response lifecycle.** Whether a response started, finished, failed or was canceled is on the response object, not in the conversation’s items.
# Extensions
> Everything the helping.io API adds beyond the Open Responses specification rides a namespaced prefix, and what you may see come back under it.
The Open Responses specification lets an implementer add fields under a namespaced prefix without changing any enum, union, or required field. This API’s prefix is `helpingio:`. A client that does not know the prefix can strip every `helpingio:` key and still parse the result. A client that does know it gets a strict superset.
## What you may see in output
[Section titled “What you may see in output”](#what-you-may-see-in-output)
Responses, output items and function calls may carry `helpingio:` keys, or names under the prefix. Ignore any you do not recognize: they never change the shape of the object they sit on, so stripping the prefix always leaves a result your client can parse.
Four output names are documented:
* `helpingio:error` sits on a `function_call_output` that reports a call this API refused or a tool that failed, and carries a `code` and a `message`. See [When this API refuses a call](/gateway/function-calling/#when-this-api-refuses-a-call).
* `helpingio:unreadable` sits on a list of conversation items. It names each item on the page that is stored but could not be read, with its `id`, the `code` `item_unreadable` and a `message`. The other items are returned as usual, and the key is absent when every item was read. See [List items](/gateway/conversations/#list-items).
* `helpingio:tool_calls_dropped` sits on a response that runs under a `max_tool_calls`. It is the number of tool calls cut from that response, past your `max_tool_calls` or past your organization’s limit for one turn, which were neither returned nor run. The key is absent when nothing was cut. See [Compatibility and versioning](/gateway/compatibility/#behavior-that-differs-from-what-you-might-expect).
* `helpingio:warnings` sits on a response that accepted a request field and did not apply it, or applied it in place of another. It is a list; each entry has a `type` (`unsupported` or `compatibility`), the `param` it is about, and a `message` for a person to read. Switch on `type` and `param`. The key is absent when there is nothing to report. The shape follows the call warnings of the Vercel AI SDK.
The `helpingio:` names that are public today:
* `helpingio:error`
* `helpingio:tool_calls_dropped`
* `helpingio:unreadable`
* `helpingio:warnings`
## How extensions evolve
[Section titled “How extensions evolve”](#how-extensions-evolve)
New extensions are added under the `helpingio:` prefix and nowhere else. A new field is a compatible change; renaming or removing one is a breaking change and would require a new API prefix. The full policy is on [Compatibility and versioning](/gateway/compatibility/#versioning-policy).
# Function calling
> Define function tools in a request, receive function_call items, and post the results back. The standard Responses API tool loop works unchanged.
You can give the model tools that your own code executes. Pass them in `tools`, receive `function_call` items when the model wants one run, execute it, and send the output back as a `function_call_output` item. This is the Responses API’s native function-calling loop, so the official SDKs’ helpers work as they do against OpenAI.
## The round trip
[Section titled “The round trip”](#the-round-trip)
1. **Send a turn with `tools`.**
POST /v1/responses
```json
{
"input": [
{
"content": "What's the weather like in San Francisco?",
"role": "user",
"type": "message"
}
],
"model": "anthropic/claude-sonnet-5",
"tools": [
{
"description": "Get the current weather for a location",
"name": "get_weather",
"parameters": {
"properties": {
"location": {
"description": "The city and state, e.g. San Francisco, CA",
"type": "string"
}
},
"required": [
"location"
],
"type": "object"
},
"type": "function"
}
]
}
```
2. **Read the `function_call` item.** The response carries a `function_call` item in `output`, after any text the model produced alongside it. The turn itself is finished: the work waiting is yours, and a streaming request ends its stream here.
```json
{
"background": false,
"completed_at": 1767225600,
"conversation": null,
"created_at": 1767225600,
"error": null,
"frequency_penalty": 0.0,
"id": "resp_ZXhhbXBsZS9mYy90b29sLWNhbGwAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"incomplete_details": null,
"instructions": null,
"max_output_tokens": null,
"max_tool_calls": null,
"metadata": null,
"model": "anthropic/claude-sonnet-5",
"object": "response",
"output": [
{
"content": [
{
"annotations": [],
"logprobs": [],
"text": "calling the tool",
"type": "output_text"
}
],
"id": "msg_00000000000000000000000000000001",
"role": "assistant",
"status": "completed",
"type": "message"
},
{
"arguments": "{\"location\":\"San Francisco, CA\"}",
"call_id": "00000000-0000-4000-8000-000000000002",
"id": "fc_00000000-0000-4000-8000-000000000002",
"name": "get_weather",
"status": "completed",
"type": "function_call"
}
],
"parallel_tool_calls": true,
"presence_penalty": 0.0,
"previous_response_id": null,
"prompt_cache_key": null,
"reasoning": null,
"safety_identifier": null,
"service_tier": "default",
"status": "completed",
"store": true,
"temperature": 1.0,
"text": {
"format": {
"type": "text"
}
},
"tool_choice": "auto",
"tools": [
{
"description": "Get the current weather for a location",
"name": "get_weather",
"parameters": {
"properties": {
"location": {
"description": "The city and state, e.g. San Francisco, CA",
"type": "string"
}
},
"required": [
"location"
],
"type": "object"
},
"strict": null,
"type": "function"
}
],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled",
"usage": {
"input_tokens": 100,
"input_tokens_details": {
"cache_write_tokens": 0,
"cached_tokens": 0
},
"output_tokens": 20,
"output_tokens_details": {
"reasoning_tokens": 0
},
"total_tokens": 120
}
}
```
3. **Run the tool and post the output.** Send a new request whose `previous_response_id` is the response above and whose `input` contains only `function_call_output` items, one per call, keyed by `call_id`.
POST /v1/responses
```json
{
"input": [
{
"call_id": "00000000-0000-4000-8000-000000000002",
"output": "{\"temperature\": \"64F\", \"conditions\": \"foggy\"}",
"type": "function_call_output"
}
],
"model": "anthropic/claude-sonnet-5",
"previous_response_id": "resp_ZXhhbXBsZS9mYy90b29sLWNhbGwAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
```
The model continues with your result. The reply is the completed response, or another `function_call` if the model needs more.
```json
{
"background": false,
"completed_at": 1767225600,
"conversation": null,
"created_at": 1767225600,
"error": null,
"frequency_penalty": 0.0,
"id": "resp_ZXhhbXBsZS9mYy90b29sLW91dHB1dC1jb250aW51YXRpb24AAAAAAA",
"incomplete_details": null,
"instructions": null,
"max_output_tokens": null,
"max_tool_calls": null,
"metadata": null,
"model": "anthropic/claude-sonnet-5",
"object": "response",
"output": [
{
"content": [
{
"annotations": [],
"logprobs": [],
"text": "It is 64F and foggy in San Francisco.",
"type": "output_text"
}
],
"id": "msg_00000000000000000000000000000001",
"role": "assistant",
"status": "completed",
"type": "message"
}
],
"parallel_tool_calls": true,
"presence_penalty": 0.0,
"previous_response_id": "resp_ZXhhbXBsZS9mYy90b29sLWNhbGwAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"prompt_cache_key": null,
"reasoning": null,
"safety_identifier": null,
"service_tier": "default",
"status": "completed",
"store": true,
"temperature": 1.0,
"text": {
"format": {
"type": "text"
}
},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled",
"usage": {
"input_tokens": 200,
"input_tokens_details": {
"cache_write_tokens": 0,
"cached_tokens": 0
},
"output_tokens": 40,
"output_tokens_details": {
"reasoning_tokens": 0
},
"total_tokens": 240
}
}
```
The Node and Python examples use a `client` [set up for this API](/getting-started/clients/#set-up-a-client).
* Node
```js
const tools = [
{
type: "function",
name: "get_weather",
description: "Get the current weather for a location",
parameters: {
type: "object",
properties: { location: { type: "string" } },
required: ["location"],
},
},
];
let response = await client.responses.create({
model: "anthropic/claude-sonnet-5",
input: "What's the weather like in San Francisco?",
tools,
});
const calls = response.output.filter((item) => item.type === "function_call");
if (calls.length > 0) {
response = await client.responses.create({
model: "anthropic/claude-sonnet-5",
previous_response_id: response.id,
input: calls.map((call) => ({
type: "function_call_output",
call_id: call.call_id,
output: JSON.stringify(getWeather(JSON.parse(call.arguments))),
})),
});
}
console.log(response.output_text);
```
* Python
```python
import json
tools = [
{
"type": "function",
"name": "get_weather",
"description": "Get the current weather for a location",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}
]
response = client.responses.create(
model="anthropic/claude-sonnet-5",
input="What's the weather like in San Francisco?",
tools=tools,
)
calls = [item for item in response.output if item.type == "function_call"]
if calls:
response = client.responses.create(
model="anthropic/claude-sonnet-5",
previous_response_id=response.id,
input=[
{
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(get_weather(**json.loads(call.arguments))),
}
for call in calls
],
)
print(response.output_text)
```
Stock SDK loops resend history, and that is fine
Some client libraries resend the whole transcript alongside the `function_call_output`: the earlier user message, the `function_call`, and any `reasoning`. When you are answering a tool call, every one of those is already part of the conversation, so the API ignores them and acts on the tool output alone. The loop works unchanged, and the model is not shown the same exchange twice.
## Rules for tool definitions
[Section titled “Rules for tool definitions”](#rules-for-tool-definitions)
A request that breaks one of these is a `400` naming the problem, and nothing is created or charged.
* **Names** are 1 to 64 characters of `[a-zA-Z0-9_]` and must be unique within the request. Give each tool a distinct name.
* **Names must not match a built-in platform tool.** Every built-in tool counts, whether or not your request would use it. A match is a `400` naming the tool; rename yours.
* **Your tools are yours to execute.** The API hands you `function_call` items and waits for your output; it never executes a tool you defined.
## Controlling which tool is called
[Section titled “Controlling which tool is called”](#controlling-which-tool-is-called)
`tool_choice` and `allowed_tools` work as in the Responses API, with two properties worth knowing.
| Value | Effect |
| ------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `"auto"` | The model decides. Default |
| `"none"` | No tool may be called this turn |
| `"required"` | The model must call some tool on its first call |
| `{"type": "function", "name": "get_weather"}` | The model must call that tool on its first call |
| `{"type": "allowed_tools", "mode": "auto" \| "required", "tools": [...]}` | Restrict the callable set to a subset, optionally forcing a call |
* **Forcing applies to the first model call only.** After a tool result comes back, `required` and a named function relax to `auto`. Restrictions (`none`, and the `allowed_tools` subset) persist for the whole turn.
* **A choice the API cannot honor is a `400`:** a name not in the tool set, `required` with no tools, an empty `allowed_tools` list, an `allowed_tools` mode of `none`, a function choice or `allowed_tools` entry with no `name`, or any forced choice on a selection that does not start with a model call. The message says which; define the tool you name, or use `auto`.
`strict` on a function definition is recorded and echoed but not enforced. Check each `function_call`’s arguments against your schema in your own code before you run the tool.
## Tools across turns
[Section titled “Tools across turns”](#tools-across-turns)
* **Posting a tool output continues the same response.** `tools`, `tool_choice`, and `model` on that request are ignored. Stock SDK loops resend the original body verbatim, and that works.
* **A new turn starts fresh.** `tools` apply per turn. A turn that omits them has no request tools, even if the previous turn defined some.
## Reporting a tool failure
[Section titled “Reporting a tool failure”](#reporting-a-tool-failure)
There is no error field on `function_call_output`. If your tool fails, put a description of the failure in `output`. The model reads it as the tool’s result and carries on. A `status` on the item is accepted and ignored, and a failure you report this way counts as a completed tool call whose output happens to describe an error.
A tool error never fails the response.
## When this API refuses a call
[Section titled “When this API refuses a call”](#when-this-api-refuses-a-call)
The model can ask for a call this API will not execute: arguments your `parameters` schema rejects, a call repeated with the same arguments, or a tool you did not offer. The response then reports the call and its result together. The `function_call` carries the arguments the model sent, and a `function_call_output` with the same `call_id` carries the reason as its `output`. Its `status` is `completed`, because the call was answered, and a `helpingio:error` object on the item says why, with a `code` and a `message`. The code is `tool_args_invalid`, `duplicate_tool_call` or `unknown_tool` for those refusals. Any other failure reports `tool_failed` with a fixed message. You do not answer this call: the model has already read the reason, and it decides what to do next in the same response.
A stream reports the same two items, with the same fields, as they happen.
# Retrieve, cancel and delete
> Read a stored response back by id, including the usage you were charged for, cancel one, or delete one response.
Every response the API creates is stored. You can read it back by id, cancel it, and delete it.
The Node and Python examples use a `client` [set up for this API](/getting-started/clients/#set-up-a-client).
## Retrieve a response
[Section titled “Retrieve a response”](#retrieve-a-response)
* curl
```bash
curl https://api.helping.io/v1/responses/resp_… \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
```
* Node
```js
const response = await client.responses.retrieve("resp_…");
console.log(response.status, response.usage);
```
* Python
```python
response = client.responses.retrieve("resp_…")
print(response.status, response.usage)
```
The body is the same response object `POST /v1/responses` returns.
```json
{
"background": false,
"completed_at": 1767225600,
"conversation": null,
"created_at": 1767225600,
"error": null,
"frequency_penalty": 0.0,
"id": "resp_ZXhhbXBsZS9yZXNwL3JldHJpZXZlAAAAAAAAAAAAAAAAAAAAAAAAAA",
"incomplete_details": null,
"instructions": null,
"max_output_tokens": null,
"max_tool_calls": null,
"metadata": null,
"model": "anthropic/claude-sonnet-5",
"object": "response",
"output": [
{
"content": [
{
"annotations": [],
"logprobs": [],
"text": "Stored for later.",
"type": "output_text"
}
],
"id": "msg_00000000000000000000000000000001",
"role": "assistant",
"status": "completed",
"type": "message"
}
],
"parallel_tool_calls": true,
"presence_penalty": 0.0,
"previous_response_id": null,
"prompt_cache_key": null,
"reasoning": null,
"safety_identifier": null,
"service_tier": "default",
"status": "completed",
"store": true,
"temperature": 1.0,
"text": {
"format": {
"type": "text"
}
},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled",
"usage": {
"input_tokens": 100,
"input_tokens_details": {
"cache_write_tokens": 0,
"cached_tokens": 0
},
"output_tokens": 20,
"output_tokens_details": {
"reasoning_tokens": 0
},
"total_tokens": 120
}
}
```
Retrieval exists for two jobs:
* **Finishing long work.** A non-streaming request that hits its 90 second wait returns an in-progress object. Poll this endpoint until `status` is terminal: `completed`, `incomplete`, `failed` or `cancelled`. A streaming request whose stream closed at the 300 second ceiling works the same way.
* **Billing reconciliation.** `usage` lives on the stored response, so a client whose connection dropped can find out what it was charged. A failed response retrieves with its `usage` and its `error`.
Two properties differ from what you may expect of other Responses APIs:
* **Retrieval reflects the response’s current state,** not a snapshot frozen at the moment it was first returned. A response that has since finished reads as finished.
* **A response still waiting on your tool output** retrieves with its `function_call` items, a status of `completed`, and `usage`. That turn is finished, and the work waiting is yours. Like every `usage` here, the figure covers the whole exchange so far rather than the one turn, so a conversation that runs through several tool rounds reports the running total each time. A response still in progress omits `usage` entirely until it settles, so read it with a presence check rather than expecting a null.
## Cancel a response
[Section titled “Cancel a response”](#cancel-a-response)
* curl
```bash
curl -X POST https://api.helping.io/v1/responses/resp_…/cancel \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
```
* Node
```js
await client.responses.cancel("resp_…");
```
* Python
```python
client.responses.cancel("resp_…")
```
Cancel works on any response your key created. OpenAI cancels only a response created with `background: true`. This API creates no response in the background, so `POST /v1/responses/{id}/cancel` cancels any response the calling key created, including one still being generated after its request returned. A response that has already finished is returned unchanged.
## Delete a response
[Section titled “Delete a response”](#delete-a-response)
* curl
```bash
curl -X DELETE https://api.helping.io/v1/responses/resp_… \
-H "Authorization: Bearer $HELPINGIO_API_KEY"
```
* Node
```js
await client.responses.delete("resp_…");
```
* Python
```python
client.responses.delete("resp_…")
```
```json
{
"deleted": true,
"id": "resp_ZXhhbXBsZS9yZXNwL2RlbGV0ZQAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"object": "response.deleted"
}
```
This deletes **one response**. It stops being readable, it can no longer be continued through `previous_response_id`, and its messages and tool calls drop out of its conversation’s [item list](/gateway/conversations/#list-items). Later turns in the conversation no longer send its messages to the model or count them against the context window. Every other response in the conversation is untouched, and the conversation itself stays usable.
Deleting the same response twice is not an error, so a retry is safe. Once its conversation has been deleted, deleting one of its responses is a `404`.
Deleting keeps your billing records
A delete stops the data being readable at once, and its content is erased 30 days after the delete. It does not remove what you were charged or the record of the calls that produced it, so your spend history and invoices are unaffected. There is no undelete.
To remove a whole history rather than one turn, delete the conversation: [`DELETE /v1/conversations/{id}`](/gateway/conversations/#read-label-and-delete).
## Ownership and the uniform 404
[Section titled “Ownership and the uniform 404”](#ownership-and-the-uniform-404)
These endpoints, and every route of the [Conversations API](/gateway/conversations/), answer one `404` for every reason you might not have access to a response or a conversation: a malformed id, an id that does not exist, a response created by another organization, or a response created by a different API key in your own organization. The envelope is the same in every case, with type `not_found_error` and code `resource_not_found`.
```json
{
"error": {
"message": "Unknown response id",
"type": "not_found_error",
"param": null,
"code": "resource_not_found"
}
}
```
The practical consequence: **use the same API key to retrieve and delete that you used to create.** A rotated key keeps access to its conversations; revoking a key ends it.
## Rate limits on these endpoints
[Section titled “Rate limits on these endpoints”](#rate-limits-on-these-endpoints)
Retrieving, canceling and deleting are rate limited separately from creating responses. See [Rate limits](/billing/rate-limits/#retrieving-canceling-and-deleting).
# Streaming
> Set stream to true to receive server-sent events as the output is produced. The event vocabulary is the Open Responses one, with exactly one terminal event per stream.
Set `stream: true` on `POST /v1/responses` and the reply is a server-sent events body instead of a single JSON object. Each event’s `event:` name is the same as the `type` field inside its `data:` payload, which is what the official SDKs’ stream parsers expect.
The Node and Python examples use a `client` [set up for this API](/getting-started/clients/#set-up-a-client).
* curl
```bash
curl -N https://api.helping.io/v1/responses \
-H "Authorization: Bearer $HELPINGIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-sonnet-5",
"stream": true,
"input": "Count from 1 to 5."
}'
```
* Node
```js
const stream = await client.responses.create({
model: "anthropic/claude-sonnet-5",
stream: true,
input: "Count from 1 to 5.",
});
for await (const event of stream) {
if (event.type === "response.output_text.delta") {
process.stdout.write(event.delta);
}
if (event.type === "response.completed") {
console.log("\nusage:", event.response.usage);
}
}
```
* Python
```python
stream = client.responses.create(
model="anthropic/claude-sonnet-5",
stream=True,
input="Count from 1 to 5.",
)
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
elif event.type == "response.completed":
print("\nusage:", event.response.usage)
```
Items nest, and each level closes only once the level inside it has. A message holds a content part, which holds the text, so the text finishes first, then its part, then the item. A response of one text message is this, captured:
The event order
```json
[
"response.created",
"response.in_progress",
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.output_text.done",
"response.content_part.done",
"response.output_item.done",
"response.completed"
]
```
Each of those names is an `event:` line with its `data:` payload underneath. Items that hold no content part are shorter by two events: reasoning closes with `response.reasoning_text.done` and then `response.output_item.done`, and a function call closes with `response.function_call_arguments.done` and then `response.output_item.done`. A response carrying several items opens and closes them one at a time, between the same `response.created` and terminal event.
A terminal event’s payload in full, captured from the same response, is one `response.completed` event carrying the same response object a non-streaming call returns:
The terminal event
```json
{
"response": {
"background": false,
"completed_at": 1767225600,
"conversation": null,
"created_at": 1767225600,
"error": null,
"frequency_penalty": 0.0,
"id": "resp_ZXhhbXBsZS9zdC9zdHJlYW0tdGVybWluYWwAAAAAAAAAAAAAAAAAAA",
"incomplete_details": null,
"instructions": null,
"max_output_tokens": null,
"max_tool_calls": null,
"metadata": null,
"model": "anthropic/claude-sonnet-5",
"object": "response",
"output": [
{
"content": [
{
"annotations": [],
"logprobs": [],
"text": "Streaming hello.",
"type": "output_text"
}
],
"id": "msg_00000000000000000000000000000001",
"role": "assistant",
"status": "completed",
"type": "message"
}
],
"parallel_tool_calls": true,
"presence_penalty": 0.0,
"previous_response_id": null,
"prompt_cache_key": null,
"reasoning": null,
"safety_identifier": null,
"service_tier": "default",
"status": "completed",
"store": true,
"temperature": 1.0,
"text": {
"format": {
"type": "text"
}
},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled",
"usage": {
"input_tokens": 100,
"input_tokens_details": {
"cache_write_tokens": 0,
"cached_tokens": 0
},
"output_tokens": 20,
"output_tokens_details": {
"reasoning_tokens": 0
},
"total_tokens": 120
}
},
"sequence_number": 8,
"type": "response.completed"
}
```
## Event vocabulary
[Section titled “Event vocabulary”](#event-vocabulary)
Every event carries a strictly increasing `sequence_number`, and `output_index` is stable for the lifetime of an item.
| Event | Terminal | When |
| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `response.created` | | The response exists. The payload carries the response object with an empty `output` |
| `response.in_progress` | | The response is producing output |
| `response.output_item.added` | | An output item (message, function call, reasoning) opened |
| `response.content_part.added` | | A content part inside a message opened |
| `response.output_text.delta` | | A chunk of assistant text |
| `response.output_text.done` | | The complete text of that part |
| `response.refusal.delta` | | The model declined and gave an explanation, which streams as the text of a `refusal` content part after any text already produced. Not sent when there is no explanation |
| `response.refusal.done` | | The refusal part is complete and carries its text, which is empty when there is no explanation. The response then completes with `response.completed` |
| `response.function_call_arguments.delta` | | A chunk of a function call's JSON arguments |
| `response.function_call_arguments.done` | | The complete arguments for that call |
| `response.reasoning_text.delta` | | A chunk of reasoning content, when the model produces reasoning |
| `response.reasoning_text.done` | | The complete reasoning content |
| `response.content_part.done` | | A content part inside a message closed |
| `response.output_item.done` | | An output item closed |
| `response.completed` | Yes | The payload is the full response object, including `usage` |
| `response.failed` | Yes | The response object carries `error.code` and `error.message` |
| `response.incomplete` | Yes | The response stopped early, for example at `max_output_tokens`. Read `incomplete_details.reason` |
| `error` | Yes | The final state was not known when the stream ended, with code `response_unresolved`. The response may still complete: retrieve it by id, or retry |
Reasoning events arrive before the text they precede, when the model produces reasoning at all.
### Events you will not receive
[Section titled “Events you will not receive”](#events-you-will-not-receive)
OpenAI’s Responses schema declares events beyond the ones above, and the published [API reference](/reference/api/) carries the whole vocabulary, so a client generated from it can hold names this API never sends. Nothing below ever arrives, and a handler for one of them never runs. What you get instead is in the second column.
| Event | What you get instead |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `response.queued` | A stream opens with `response.created` and `response.in_progress`, so read `response.created` as the start |
| `response.output_text.annotation.added` | Output text is not annotated here, so `annotations` on a text part is always empty |
| `response.reasoning_summary_part.added` | Reasoning is not summarized. The reasoning item opens with `response.output_item.added` |
| `response.reasoning_summary_part.done` | Reasoning is not summarized. The reasoning item closes with `response.output_item.done` |
| `response.reasoning_summary_text.delta` | Reasoning is not summarized. The reasoning itself streams on `response.reasoning_text.delta` |
| `response.reasoning_summary_text.done` | Reasoning is not summarized. The complete reasoning arrives on `response.reasoning_text.done` |
## What a stream guarantees
[Section titled “What a stream guarantees”](#what-a-stream-guarantees)
* **Exactly one terminal event.** Every stream ends with one of `response.completed`, `response.failed`, `response.incomplete`, or `error`. Never a bare drop. A response that was canceled ends with `response.failed` whose response has `status: "cancelled"` and no `error`, so read the status rather than assuming a failure.
* **A keep-alive every 15 seconds.** While the stream is quiet you get an SSE comment frame, so proxies between you and the API do not time the connection out. Standard SSE parsers ignore comments.
* **A 300 second ceiling.** A stream is closed after 300 seconds with its terminal event. The response may not be finished at that point: retrieve it with `GET /v1/responses/{response_id}`.
* **Failures are in-band.** Once the stream is open the HTTP status has already been sent. A failure after that arrives as `response.failed` whose `error` carries the same codes and messages as a non-streaming error. A failure of the stream itself, rather than of the response, arrives as an `error` event instead, which carries `code`, `message` and `param` on the event itself rather than nested: read `event.message`. Those codes are below.
* **Tool calls end the stream.** When the model calls one of your function tools, the stream emits the `function_call` item and then its terminal event, carrying that item on the response. You post the tool output as the next request. See [Function calling](/gateway/function-calling/).
Streams and disconnects
Closing a streaming connection early does not cancel the response, and you are billed for what it produces. Closing a non-streaming request does cancel it. To stop a response whose connection you no longer hold, cancel it by id with `POST /v1/responses/{id}/cancel`.
## What an `error` event can say
[Section titled “What an error event can say”](#what-an-error-event-can-say)
An `error` event is terminal and carries a `code` of its own, read the same way as the `code` on an HTTP error:
| `code` | Meaning |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `response_unresolved` | The stream ended before a result was ready. The response may still finish: retrieve it by the id response.created carried. Retrying without the same Idempotency-Key starts a second response. |
Everything else that can go wrong reaches you as a response object, on `response.failed` or `response.incomplete`. See [Errors](/gateway/errors/#errors-during-a-stream).
# Compatibility and versioning
> Which parts of the Responses API the helping.io API does not implement, where it differs, and the versioning policy for the surface.
Which parts of the Responses API this API does not implement, where it behaves differently, and the versioning policy. Which clients work, and how to point them here, is on [Clients and SDKs](/getting-started/clients/).
## Not implemented
[Section titled “Not implemented”](#not-implemented)
Anything not listed as available on these pages is not on the wire. The items below are the ones a Responses API client is most likely to reach for.
### Request fields that are refused
[Section titled “Request fields that are refused”](#request-fields-that-are-refused)
Each is a `400` naming the field, so nothing is silently dropped. The wait window a `background` request would have outlived is 90 seconds, and a stored response is removed by deleting it: [Retrieve and delete](/gateway/retrieve-and-delete/).
| Field | What happens instead |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `background` | Not accepted. Send the request without it: a non-streaming request that outlives its wait window returns the response in progress, and you retrieve it later with GET /v1/responses/{id}. |
| `include` | Each value either returns its data or is refused. reasoning.encrypted\_content adds each reasoning item's encrypted content to the response. message.output\_text.logprobs returns log probabilities on a model that produces them and is refused, naming the model, on one that does not; no current model produces them. file\_search\_call.results, web\_search\_call.results, web\_search\_call.action.sources and code\_interpreter\_call.outputs name hosted tools this API does not offer, and computer\_call\_output.output.image\_url names the computer tool, which is not offered either, so each is refused. message.input\_image.image\_url is refused for the reason image input is: image input is not accepted, because only text reaches the model. |
| `reasoning` | Not accepted; omit it. Reasoning effort is fixed by the configuration your request runs on and cannot be set per request. Reasoning output still streams and is stored, so you read it the same way. |
| `store` | Not accepted. Every response is stored, which is what makes continuing a conversation and retrieving a response work. To remove one, delete it with DELETE /v1/responses/{id}: it can no longer be retrieved, and later turns no longer send it to the model or count it against the context window. |
Two more things are refused that are not request fields, so they are not in the table above.
| What | Status |
| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `input_image`, `input_file`, `input_video` parts, and `summary_text`, `reasoning_text` or `refusal` parts in a message | A `400` naming the part, on a response’s input and on items you add to a conversation. Only text reaches the model, so send what it needs as an `input_text` part: a file’s text, or a description of an image |
| `message` items with role `assistant`, `system`, or `developer` | On `POST /v1/responses` only: send the user turn only. See [How input becomes a turn](/gateway/create-a-response/#how-input-becomes-a-turn). The [Conversations API](/gateway/conversations/#add-items) accepts all four roles, which is how you seed a history that started elsewhere |
### Generation settings depend on the model
[Section titled “Generation settings depend on the model”](#generation-settings-depend-on-the-model)
The fields below are applied when the model supports them. Support differs from model to model, so a setting the model does not support, or a value outside the range it supports, is a `400` that names the model and the field, before the response starts. For example, a model that reasons by effort does not support `temperature` or `top_p`. None of these settings is accepted and then dropped.
| Field | What happens |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `frequency_penalty` | Applied when the model supports it. The response reports the value it was generated with, and 0 when none was applied. |
| `parallel_tool_calls` | Applied when the model supports it: false asks for at most one tool call per turn. The response reports the value it was generated with, and true when none was set. |
| `presence_penalty` | Applied when the model supports it. The response reports the value it was generated with, and 0 when none was applied. |
| `temperature` | Applied when the model supports it, within the model's range. A model that reasons by effort does not support it. The response reports the value it was generated with, and 1 when none was set. |
| `text` | Its format sets the output shape, and a json\_schema format constrains the output to the schema it carries, which it must include. Its verbosity sets how much detail to aim for. Each is applied when the model supports it, and the response reports the format and verbosity it was generated under. |
| `top_logprobs` | No current model returns log probabilities, so a request that sets it is a 400 naming the model and the field. The response reports 0. |
| `top_p` | Applied when the model supports it, within the model's range. A model that reasons by effort does not support it. The response reports the value it was generated with, and 1 when none was set. |
On a Claude model that thinks within a token budget, `temperature` or `top_p` is also a `400` before the response starts when thinking is on. Any other condition belongs to the model provider: when it refuses, the response fails, its `status` is `failed`, and `error` carries the provider’s reason.
The response reports each value it was generated with. Where no value was set, it reports the specification’s default: `1` for `temperature` and `top_p`, `true` for `parallel_tool_calls`, `0` for both penalties, and `text` for `text.format.type`.
A `text.format` of type `json_schema` constrains the output to the schema you supply, and must carry one: a `json_schema` format without a `schema` is a `400`.
### What each model accepts
[Section titled “What each model accepts”](#what-each-model-accepts)
Which of the settings above each model accepts, and the range it accepts them in. A setting a model’s row does not list is a `400` for that model, before the response starts. The table is generated from the models this API serves, by the same check a request meets, so it changes when a model’s support does.
| Model | Settings it accepts |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `anthropic/claude-fable-5` | `parallel_tool_calls`, `text.format` |
| `anthropic/claude-fable-5-1` | `parallel_tool_calls`, `text.format` |
| `anthropic/claude-opus-4-8` | `parallel_tool_calls`, `text.format` |
| `anthropic/claude-opus-5-5` | `parallel_tool_calls`, `text.format` |
| `anthropic/claude-sonnet-5` | `parallel_tool_calls`, `text.format` |
| `anthropic/claude-sonnet-5-5` | `parallel_tool_calls`, `text.format` |
| `google/gemini-3.1-flash-lite` | `temperature` (between 0 and 2), `top_p` (between 0 and 1), `presence_penalty`, `frequency_penalty`, `text.format` |
| `google/gemini-3.5-flash` | `temperature` (between 0 and 2), `top_p` (between 0 and 1), `presence_penalty`, `frequency_penalty`, `text.format` |
| `google/gemini-3.5-flash-lite` | `temperature` (between 0 and 2), `top_p` (between 0 and 1), `presence_penalty`, `frequency_penalty`, `text.format` |
| `google/gemini-3.6-flash` | `temperature` (between 0 and 2), `top_p` (between 0 and 1), `presence_penalty`, `frequency_penalty`, `text.format` |
| `google/gemini-3.7-flash` | `temperature` (between 0 and 2), `top_p` (between 0 and 1), `presence_penalty`, `frequency_penalty`, `text.format` |
| `google/gemini-3.8-flash` | `temperature` (between 0 and 2), `top_p` (between 0 and 1), `presence_penalty`, `frequency_penalty`, `text.format` |
| `openai/gpt-5.4-mini` | `parallel_tool_calls`, `text.format`, `text.verbosity` (one of low, medium, high) |
### Request fields that are advisory
[Section titled “Request fields that are advisory”](#request-fields-that-are-advisory)
These are accepted and never refused, because ignoring one of them changes nothing about the answer you get back. What each asks for is not done here yet, and the response says so rather than repeating your value: it reports what was applied. **That report is a fact about the response, not a missing field.** It is how you tell that the setting did not take effect, and the day one of them does take effect the same field is how you will tell that too.
| Field | What happens, and what the response reports |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prompt_cache_key` | Your key does not route the provider cache, and the response reports it as null. Caching still happens, keyed by the conversation, so a continued conversation keeps its cached prefix without you sending anything. |
| `service_tier` | There is one tier. Asking for another runs on that one, and the response always reports that one tier rather than the tier you asked for. |
### Endpoints that do not exist
[Section titled “Endpoints that do not exist”](#endpoints-that-do-not-exist)
| Path | Status |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `POST /v1/chat/completions` | Not available. The API speaks the Responses API, which OpenAI introduced in 2025 as the successor to Chat Completions. If your app uses Chat Completions today, OpenAI’s [guide on moving to Responses](https://developers.openai.com/api/docs/guides/migrate-to-responses) covers the changes, and the same code works against this API once migrated |
| `POST /v1/responses/compact` | Not available. Long conversations are not compacted on request; start a new conversation when you want a fresh context |
| `GET /v1/responses/{id}/input_items` | Not available. Read the conversation’s items instead: [Conversations](/gateway/conversations/#list-items) |
| `/v1/embeddings`, `/v1/completions`, `/v1/files` | Not available. This API serves the Responses API only. Use your provider directly for embeddings, and send file content as text in your input |
| WebSocket transport | Not available. The API is HTTP and server-sent events only. For token-by-token output, set `stream: true` and read the SSE body: [Streaming](/gateway/streaming/) |
### How `truncation` differs
[Section titled “How truncation differs”](#how-truncation-differs)
`truncation` is applied, and it behaves as the Responses API describes: `disabled`, the default, refuses a turn that does not fit the model’s context window, and `auto` drops the oldest turns so the turn can proceed. Five differences are worth knowing before you rely on either value.
| Difference | What it means for you |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The size of a turn is **estimated, not tokenized** | The estimate reads about four characters per token. To stay safe, a turn must fit in the window left after the reply’s `max_output_tokens`, less 15 percent of it, less 2000 tokens for the platform’s own instructions and the estimated size of your tools. So a turn near the model’s real limit can be refused although a token count would have fit it |
| `auto` drops **whole turns from the oldest end**, never part of one | The oldest end is the end the Responses API documents dropping from, so that much is the same |
| The turn you just sent is **never dropped** | If it alone does not fit, the request fails rather than being emptied. This is our guarantee, not a shared one: the Responses API documents nothing about which items `auto` may keep |
| `auto` trims **further than it strictly needs to** | It frees 10 percent of the usable budget beyond what the turn requires, so the next few turns fit without dropping anything more. Trimming to the line would drop a turn on every turn after the first, and each drop costs you the cached prefix |
| A refused turn is **not stored** | The conversation is left exactly as it was, so you can send a shorter turn against the same `previous_response_id` |
The refusal is a `400` with the code `context_length_exceeded`, which is the code OpenAI’s own clients branch on, and a message naming the window and the estimate. Match the code rather than the message. Retrieving a stored response reports the `truncation` the turn actually ran under.
One thing worth knowing before you build on either value: **OpenAI marked `truncation` deprecated**, in favor of a compaction setting this API does not implement. Read from their published API description on 2026-09-21, at revision `ddface9b`. It is applied here, and any change to that would follow the versioning policy below.
### Behavior that differs from what you might expect
[Section titled “Behavior that differs from what you might expect”](#behavior-that-differs-from-what-you-might-expect)
* **No stream resume.** `Last-Event-ID` is not honored, and `starting_after` is refused with a `400` rather than ignored, so a client that asks to resume learns that it cannot. If a stream drops, retrieve the response by id or retry the request with the same `Idempotency-Key`.
* **Some declared stream events are never sent**, among them the reasoning summary ladders. Each one, and what arrives in its place, is listed under [Events you will not receive](/gateway/streaming/#events-you-will-not-receive).
* **Where a refusal’s words arrive depends on the model’s provider.** When a model declines, its message gains a `refusal` content part after any text it already produced, and the response completes, as OpenAI reports a refusal. For Anthropic models, the part’s `refusal` text is the model’s explanation when it gives one. For OpenAI models, the words of the refusal arrive first as ordinary `output_text`, and the part’s `refusal` text is empty. For Google models, the part’s `refusal` text is empty.
* **`max_tool_calls` counts every tool call.** OpenAI applies it to built-in tools only. Here it counts every call the model makes while generating the response, your own function tools included. Your organization’s policy also limits how many calls one turn may make. Calls past either limit are neither returned nor run, and `helpingio:tool_calls_dropped` on the response says how many were cut. When you send function call outputs, the response that continues from them starts a new count. A value below `1` is a `400`.
* **`safety_identifier` reaches only providers with a field for it.** It is sent, as a keyed hash of your value, to models whose provider has a per-end-user field, and not to Google models, which have none. The response still reports the value you sent, and `helpingio:warnings` says when it was not sent. The deprecated `user` is used in its place when it is absent.
* **Cancel works on any response your key created**, not only one created with `background: true`. See [Cancel a response](/gateway/retrieve-and-delete/#cancel-a-response).
* **Retrieval is live, not a snapshot.** `GET /v1/responses/{id}` reflects the response’s current state.
* **When you answer a tool call, resent history is ignored rather than appended.** Client libraries usually resend the whole transcript alongside the `function_call_output`. Those items are already in the conversation, so this API acts on the tool output and ignores the rest, where OpenAI appends what you send. The result is the same conversation without a second copy of the same exchange. On a *new* conversation there is nothing to duplicate, so a `function_call` sent with its matching `function_call_output` is carried in as real history.
* **A retrieve reports what the response ran under, not what you sent.** `metadata`, `previous_response_id`, `truncation` and `instructions` come back as you sent them. `model` comes back as the model that ran, and `max_output_tokens` as the limit that applied, which is lower than you asked for when the model’s own maximum is lower. `tools` and `tool_choice` come back as the *first* turn of the conversation set them, because a turn that answers a tool call runs with the settings of the turn that asked: this API ignores `tools`, `tool_choice`, `max_tool_calls` and `safety_identifier` when you send tool output, as the standard function-calling loop does when it re-sends your request body. So if you changed any of them while answering a tool call, the retrieve reports the earlier values, which are the ones the model actually had. The response that continues from your tool output counts its tool calls from zero against that same `max_tool_calls`.
* **`previous_response_id` names the turn a response continues, not always its immediate predecessor.** Answering a tool call produces a new response without starting a new turn, so every response of one turn reports the same `previous_response_id`: the response that turn continued. Walking the chain backwards therefore steps over the tool-answer responses in between. Use the conversation’s item list to read a turn’s full history in order.
* **There is no way to change a response’s `metadata` after it is created.** That is not a limitation of this API: the Responses API has no update-response route at all, so metadata is set once with the response. A conversation’s labels can be replaced, through [`POST /v1/conversations/{id}`](/gateway/conversations/#read-label-and-delete).
* **`metadata` is never sent to a model**, on a response as on a conversation. It is storage and labeling, not instruction.
* **A response reports `conversation` only when you named one.** Send `conversation` and the response carries that id, on the create, on the stream and on a later retrieve. Send none and it is `null`, even though your history is still kept for you, because a conversation you did not ask for is not one you can use.
* **Deleting is a tombstone, and it keeps your billing records.** Both `DELETE /v1/responses/{id}` and `DELETE /v1/conversations/{id}` stop the data being readable at once, and erase its content 30 days after the delete, without destroying what you were charged or the record of the calls that produced it. Deleting the same thing twice is not an error. There is no undelete.
* **A deleted response also disappears from its conversation’s items.** It stops being readable, stops being continuable through `previous_response_id`, and its messages and tool calls drop out of `GET /v1/conversations/{id}/items`. The rest of the conversation is untouched.
* **A response still running omits `usage`.** The field appears once the response settles, so read it with a presence check rather than expecting `null`. Completed and failed responses carry it, and so does a response that ends in a function call.
* **`usage` counts the whole exchange, not one turn.** A conversation that runs through several tool rounds reports the running total every time, so the figure grows as you go and the last one is what the exchange cost. It is not a per-turn breakdown, and subtracting two of them is not one either.
* **Error codes are this API’s own, not OpenAI’s.** `response.error.code` and the `code` in an error envelope come from this API’s own catalog, such as `provider_overloaded` or `insufficient_credits`. Match on the HTTP status and the error `type` when you need behavior that works against both APIs. One code is shared on purpose: `insufficient_quota`, with OpenAI’s own type and code, for an organization that cannot pay for the request. The full list is on the [Errors](/gateway/errors/) page.
* **`created_at` on a conversation is Unix seconds**, matching the Responses API’s own convention for the field.
* **Only items you added can be removed** from a conversation. Removing an item a response produced is a `400`; delete the conversation instead.
* **Two kinds of history never appear in a conversation’s item list**: a server-side tool failure, and response lifecycle. A response reports its own tool failures as items; the conversation’s list does not. See [What the item list does not show](/gateway/conversations/#what-the-item-list-does-not-show).
* **`strict` on function tools is echoed but not enforced.** Check each `function_call`’s arguments against your schema in your own code before you run the tool.
* **`function_call_output.status` is ignored**, and a failure you report in a tool output counts as a completed tool call. Say that the tool failed in the `output` text, which the model does read.
## Versioning policy
[Section titled “Versioning policy”](#versioning-policy)
The API implements the **Open Responses specification**; the OpenAI SDKs are clients that work with it. Every wire response and stream event is validated against a pinned schema before a change can ship, so the shapes on this surface are the ones your SDK already expects. There is one prefix on the API, `/v1`, and the rules below say what a client can build against without watching for announcements.
Where this surface and OpenAI’s differ, the difference is listed on this page. A field the API does not implement is accepted and ignored, or refused with a `400` in OpenAI’s own error shape that names it.
### What you can rely on
[Section titled “What you can rely on”](#what-you-can-rely-on)
* **Unknown request fields never fail a request.** No wire type rejects unknown fields. A field is rejected only when it asks for behavior the API cannot honor, and then with a `400` that names it, which is what lets a newer SDK keep working against this surface.
* **An unknown value of a known field is a `400` that names it.** An item `type`, a `service_tier` or a tool `type` this API does not define is refused, with the field in `param` and its path, such as `input[1]`, in the message. The message quotes the value when it is a short name rather than free text.
* **Extensions are namespaced.** Everything beyond the specification carries the `helpingio:` prefix and never changes an enum, union, or required field of it. See [Extensions](/gateway/extensions/).
### Breaking and compatible changes
[Section titled “Breaking and compatible changes”](#breaking-and-compatible-changes)
| Breaking, would require a new prefix | Compatible, may land under `/v1` |
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| Removing or renaming any field, status value, event type, or error code a client validates against the schema | A new `helpingio:` field |
| Changing the meaning of an existing one | A new error code under an existing HTTP status |
| Narrowing what is accepted | A loosened acceptance rule |
| | A new endpoint |
Changelog
There is no customer-facing changelog for the API yet. Additive changes are announced on the page that owns the topic.
# For coding agents
> This site publishes itself as llms.txt, a compact abridged file, a quickstart set, and a skill your coding agent can install.
Most of the code written against this API is written next to an assistant. Every page here is therefore published twice: once as HTML for you, and once as plain text for whatever is reading over your shoulder. All of it is generated from the same pages, on every build, so none of it can fall behind what this site says.
## The text exports
[Section titled “The text exports”](#the-text-exports)
| File | What it holds | Read it when |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| [`/llms.txt`](/llms.txt) | The entry point: what this API is, the base URL, how to authenticate, and links to everything below | You want the smallest possible orientation |
| [`/llms-small.txt`](/llms-small.txt) | Every page, abridged, with asides and collapsed sections removed | The whole API has to fit in one context window |
| [`/llms-full.txt`](/llms-full.txt) | Every page, complete, including the compatibility matrix and the glossary | You want nothing left out |
| [`/_llms-txt/quickstart.txt`](/_llms-txt/quickstart.txt) | Getting started, creating a response, and errors | You need to make one call and handle what comes back |
| [`/openapi/v1.json`](/openapi/v1.json) | The `/v1` contract as an OpenAPI 3.1 document | You are generating a client, or you want the exact shape of a field rather than a sentence about it |
The pages come back in reading order rather than alphabetically: the quickstart and the two pages you need to send a request and read its reply come first, and what is not implemented comes last. A reader that runs out of room part way through still has the useful half.
Point a tool at the file directly:
```bash
curl https://docs.helping.io/llms-small.txt
```
## The OpenAPI document
[Section titled “The OpenAPI document”](#the-openapi-document)
[`/openapi/v1.json`](/openapi/v1.json) is the whole `/v1` surface as OpenAPI 3.1: every route, every request and response schema, and the bearer-token security scheme. The [API reference](/reference/api/) is the same document rendered a page per operation, with `curl` and `fetch` snippets beside each one.
Neither is written by hand. The document is emitted from the same assembly the server mounts its router from, so it cannot describe a route the server does not serve, and the prose in it is the doc comments on the types themselves.
```bash
curl -o openapi.json https://docs.helping.io/openapi/v1.json
```
## The skill
[Section titled “The skill”](#the-skill)
The same material is also published as a skill, so an agent that supports skills can carry it without being told to fetch a URL. It covers authenticating, the routes, the extension fields, streaming, the tool loop, and which error codes are worth retrying, with six reference files behind it:
| File | What it holds |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| [`SKILL.md`](/skill/helpingio/SKILL.md) | The skill itself |
| [`references/errors.md`](/skill/helpingio/references/errors.md) | Every error code, its status and type, whether it carries `Retry-After`, and what it means |
| [`references/events.md`](/skill/helpingio/references/events.md) | Every stream event and when it arrives |
| [`references/constants.md`](/skill/helpingio/references/constants.md) | The fixed timings and size limits |
| [`references/glossary.md`](/skill/helpingio/references/glossary.md) | The name this platform uses for each thing |
| [`references/examples.md`](/skill/helpingio/references/examples.md) | Captured requests and responses to build a body against |
| [`references/openapi.json`](/skill/helpingio/references/openapi.json) | The same OpenAPI document, carried with the skill so an agent does not have to fetch it |
| [`scripts/smoke.sh`](/skill/helpingio/scripts/smoke.sh) | Two `curl` calls that check a key reaches the API. Takes `HELPINGIO_API_KEY` from the environment; no key is stored in it |
### Installing it
[Section titled “Installing it”](#installing-it)
The skill is not published to its marketplace repository, [helpingio/skills](https://github.com/helpingio/skills), yet. The files themselves are live and linked above: copy [`SKILL.md`](/skill/helpingio/SKILL.md) and its `references/` directory into your own skills directory in the meantime.
## A snippet for your repository
[Section titled “A snippet for your repository”](#a-snippet-for-your-repository)
For an assistant that reads a file at the root of your repository instead, [`/skill/AGENTS.md`](/skill/AGENTS.md) is the same guidance in a form you can paste in. [`/skill/cursor/helpingio.mdc`](/skill/cursor/helpingio.mdc) is the same text as a Cursor rule, which belongs in `.cursor/rules/`.
* AGENTS.md
```bash
curl -o AGENTS.md https://docs.helping.io/skill/AGENTS.md
```
* Cursor
```bash
mkdir -p .cursor/rules
curl -o .cursor/rules/helpingio.mdc https://docs.helping.io/skill/cursor/helpingio.mdc
```
Both are short on purpose. They say what the API is, where it lives, how a request authenticates, that one request carries one turn, and that error handling branches on `code`. Anything longer belongs in the skill or in the text exports, which your assistant can fetch when it needs them.
# Glossary
> The names helping.io uses for the things you work with, and what each one means.
Every name below is the one name this platform uses for that thing, on this site, in the apps, and on the wire. The list is generated from the term registry, so a name here is the name everywhere.
* API key
A secret that starts with sk\_ and identifies your organization to the API. Shown once when created; rotate the key if it is lost.
* Conversation
The history the server keeps for a series of responses. Created for you by a plain call, or explicitly, and named on a response by its id.
* credits
Prepaid balance that requests are billed against at the real cost of the model calls they make.
* Default rate limit
The request rate limit an organization has unless a different one is set for it. The rate-limit headers carry the live values.
* end\_user\_blocked
Error code for a response the model provider refused because it refuses requests for the end user named by the request's safety\_identifier or user.
* helpingio:tool\_calls\_dropped
Response field: on a response that runs under a max\_tool\_calls (sent with the request that started the turn), how many tool calls were cut, which were neither returned nor run. Absent when nothing was cut.
* helpingio:unreadable
List field naming the items on a page that are stored but could not be read. The other items are returned as usual.
* helpingio:warnings
Response field: a list of the request fields the response accepted and did not apply (type unsupported) or applied in place of another (type compatibility), each with its param and a message. Absent when there is nothing to report.
* item\_unreadable
Error code for an item that is stored but could not be read.
* JSON Schema
The standard for describing the shape of JSON data; used for input and output shapes and for structured output. Always written as the two words.
* Launch Plan
The name of the helping.io plan.
* Max output tokens
The most tokens a single turn may produce.
* Model
A language model you can select, named provider/model, for example anthropic/claude-sonnet-5.
* organization
The account that owns API keys and billing. Every request runs on behalf of one organization.
* plan
What an organization pays each month to use helping.io. Requests also need credits, which are bought separately from the plan.
* provider
The company whose model serves a request; the first half of a model id.
* Reasoning
How much thinking a model does before answering; the available settings depend on the model.
* response
The object POST /v1/responses creates and GET /v1/responses/{id} returns: the model's output, its status, and usage.
* Responses API
The OpenAI Responses API wire format, which the Open Responses specification follows and helping.io's /v1 implements.
* Revoke
Disable a key immediately without deleting its record.
* Rotate
Replace a key's secret with a new one; the old secret stops working, at once or after a grace period of up to 7 days.
* spend budget
An allowance in credits set for one scope (your organization, a member, or an API key); a request that would exceed it is refused with scope\_budget\_exceeded.
* Tools your API calls declare
Function tools sent in a request's tools array, executed by your code when the model calls them.