Documentation
Idempotency
One key, one turn: retry without launching — or paying for — the same conversation twice.
Sending a turn costs niucredits and produces effects: it can read libraries, run tools, send an email through a connector. Repeating it because of a network blip is not acceptable, so the Idempotency-Key header is required on both send endpoints.
Heads up
The API never generates a key for you. Doing so would turn every retry into a new turn — exactly what idempotency exists to prevent — and you would have no way to notice.
Choosing the key
- A UUID v4 per logical attempt is the simplest option and always works.
- One of your own business ids works too —
ticket-8842-summary,job-91021— and is better if your queue can re-enqueue the same job. - What matters is that it survives retries: generating it inside the retry block defeats the purpose.
Note
Only the key's SHA-256 hash is stored. You can use an internal identifier without that value landing in our tables or audit log.
What a key identifies
The reservation lives at (credential, key). A fingerprint of the request is stored alongside: method, route, chat_id and body, as canonical JSON. Repeating the key with the same fingerprint is a retry; with a different fingerprint it is a mistake on your side.
| Situation | Response | What to do |
|---|---|---|
| New key | The turn starts. | — |
| Repeated key, same body, turn finished | The same result, with Idempotent-Replayed: true. | Nothing: you already have it. |
| Repeated key, same body, turn running | 409 api_v1_turn_in_progress | Wait and retry with the same key. |
| Repeated key, different body | 409 api_v1_idempotency_conflict | Use a new key. With this one it will always fail. |
Note
A reservation lives for 24 hours. After that it is purged and reusing the key falls outside the guarantee — but it is never purged before.
What does NOT burn your key
A rejection that was the request's fault from the start does not consume the reservation. These errors give the key back intact, so you can fix and retry with the same one:
422shape errors: content too long, too many attachments, oversizedmetadata.403for a missing companion scope.404for achat_idthat belongs to another credential.409 api_v1_chat_busy, or a license rejection: they are explicitly released, because nothing was created or charged.
Heads up
By contrast, a turn that did start and failed — 502, 504 — is recorded under that key with its error terminal. Retrying with the same key returns that stored error; to truly try again, generate a new key.
What a replay looks like
On the synchronous send, the replay returns the same status and the same body, with the Idempotent-Replayed: true header. The only thing that changes is meta.request_id, which belongs to this request, so you can cross it with your log.
The same happens on the stream: the response carries Idempotent-Replayed: true and the body emits message.start and then the stored terminal event directly. Nothing is re-executed and the deltas are not replayed.
event: message.start
data: {"chat_id":"3f6b1a90-…","message_id":90213,"request_id":"<el de este request>"}
event: message.completed
data: {"id":90213,"chat_id":"3f6b1a90-…","content":"…","usage":{"prompt_tokens":2410,"completion_tokens":188,"total_tokens":2598,"provider":"OPENAI","model":"gpt-4o"}}If the process dies
Each turn has a deadline (30 minutes) set with the database clock, not the application's. If the process running it dies without writing its result, the first retry after that deadline closes the turn as an unknown outcome and hands you that terminal.
Closing is the only honest option when it is unknown whether the turn produced anything. A turn stuck in_progress forever would leave that key unusable — and you with no way to retry.
One turn per chat
Beyond idempotency, a chat allows only one turn at a time, whether it comes from the API or from someone who opened that chat in the application. The second one gets 409 api_v1_chat_busy with the message_id of the running one.
The reason is concrete: cancellation is indexed by chat, so with two API turns in the same chat, cancelling one would cancel the other and the orphan would have nobody to close it. A turn opened from the application does not count toward this limit.
Tip
If you need parallelism, use one chat per conversation. Creating chats is cheap and it is the model the API expects.
A retryable send
import time
import uuid
import requests
def send_turn(chat_id: str, content: str, token: str, *, key: str | None = None):
"""La key se genera UNA vez, fuera del bucle de reintentos."""
key = key or str(uuid.uuid4())
for attempt in range(5):
response = requests.post(
f"https://api.niucore.com/api/v1/chats/{chat_id}/messages",
headers={
"Authorization": f"Bearer {token}",
"Idempotency-Key": key,
},
json={"content": content, "permission_mode": "auto"},
timeout=(10, 1_800),
)
if response.status_code == 200:
return response.json()["data"]
code = response.json().get("message_code")
if code == "api_v1_turn_in_progress":
time.sleep(2**attempt) # misma key: el turno sigue vivo
continue
response.raise_for_status()
raise TimeoutError(f"el turno {key} no terminó a tiempo")