Skip to main content

Two error shapes, and how to tell them apart

Some failures are produced by the service; some are produced by the gateway in front of it, before the service is reached. They do not share a body shape.
Match on the HTTP status code, and, for service errors only, on error.
  • error is a stable machine-readable code: bad_request, payment_required, request_too_large, engine_error, not_found.
  • reason is a narrower, also stable code that appears on a 400 when the body parsed and a value in it was rejected: unknown_operating_point, unknown_record_kind, non_string_text and similar. It is absent when the body itself would not parse, so read it with a default and never require it. New reasons ship additively, so tolerate ones you do not recognise.
  • detail is for a human reading a log. It is not stable; do not parse it.
  • The gateway’s key is capitalised inconsistently between cases (message in some, Message in others) because those are the platform’s own bodies passed through unchanged. Do not build on that key at all.

The table

401 vs 403

These mean genuinely different things and are worth wiring to different alerts.

401: the header was missing entirely

No Authorization header at all. Rejected at the gateway before anything of ours runs. This is almost always a deployment mistake: an unset environment variable, a proxy stripping the header, a misspelled header name.

403: a header arrived and was refused

Anything else. Including an Authorization header that is present but not Bearer-shaped: that reaches authorization and is denied, it does not become a 401. Other causes: an unknown, revoked or malformed key; a suspended organisation; a control plane too stale to vouch for the key.
The 403 body deliberately does not say which cause applied. An error that distinguishes “no such key” from “suspended organisation” is an oracle for anyone probing keys, so the message is uniform on purpose, and the way to find out which one you hit is to ask us.
A 403 right after minting a key is usually a provisioning race, not a bad key. Associating a new key with its plan is not instantaneous; it can take a minute or two. Retry once after a short delay before concluding anything.

Retrying a 429

Both a rate limit and a daily quota surface as 429, and they want opposite behaviour: Branch on the body here if you can: it is the one place a gateway message is worth reading, because the status code alone cannot distinguish “retry shortly” from “retry tomorrow”. Treat it as a hint rather than a contract: it is the platform’s string, not ours, so default to capped exponential backoff when the body is missing or unrecognised, and let a circuit breaker stop the retries rather than relying on the message to tell you to. Your plan limits are approximate in both directions (why), so a 429 is a normal operating condition, not an incident. Build backoff in from the first day.

402: out of credit

402 means your workspace is out of credit. It is served, so handle it. It is a service error, not a gateway one, so it carries the error/detail shape and not the gateway’s message key:
Nothing about the credential is wrong when you see this, which is exactly why it is a separate status: a 403 says fix the key, a 402 says add credit. Match on the status, or on error, and never fold the two together.
Do not retry a 402 on a backoff. It will not clear until credit is added, from the dashboard’s Wallet page. Retrying turns an empty balance into a hot loop against an endpoint that will keep refusing.

The cases that are not errors

Reading a stand-down

Needlepath does not force a selection on every call. When it cannot find evidence that a smaller context covers the task, it stands down and hands your context back essentially intact, with fallback_used: true as the instruction to send what you submitted. This is always a 200, but the shape of that 200 depends on the operating point: np-2026-08-r3 and np-2026-08-r4 return every record, and np-2026-07-r2 returns an empty selection (outcome: "stood_down"). On np-2026-08-r4 (and on np-2026-10-r6, the default), this is a full-context fallback: every record comes back, outcome reads "engaged", and the receipt reads usage.outcome: "pass_through". Three small records at a 4,000-token budget, one of them sent with importance: 0.0:
The record sent with importance: 0.0 comes back along with the other two. importance is a scoring input, not an admission ticket: on a full-context fallback nothing is dropped for any reason, low importance included. A caller pinned to the older np-2026-07-r2 sees the shape this section used to describe exclusively: an actual empty selection, outcome: "stood_down", nothing in selected[]:
Both are the same request; only budget.operating_point differs. See the same three records under all three labels for the full comparison, including np-2026-08-r3, which behaves like r4 here. Either way: send what you were going to send. The receipt says whether the request counted; see How billing works. On some workload shapes a stand-down is the common outcome rather than a rare one, so do not alarm on it and do not treat a tokens_saved of zero as a failure.
fallback_used: true means send the context you submitted. It does not tell you why. Four different things produce it:
  1. a stand-down that returns an empty selection: top-level outcome is stood_down;
  2. a stand-down that returns every record, on np-2026-08-r3 or np-2026-08-r4, shown above: top-level outcome is engaged, every record came back, and usage.outcome reads pass_through (or fallback if the fallback changed the token total or record count);
  3. an empty or unusable record set;
  4. an engine-internal exception: selection_error is populated.
If you care about the difference, read outcome, usage.outcome, selection_error and format_metrics.engine_fallback_reason. fallback_used alone does not separate an ordinary response from an outage.
Branch on outcome, never on gate.engaged. They answer different questions and they disagree on ordinary, healthy requests.outcome is the result of the request: engaged, stood_down, escalated or, when you asked for security screening, sanitized_fallback or blocked. gate is an informational object, and its engaged and reason values are not the result. gate.engaged: false alongside outcome: "engaged" is a normal response, not a contradiction and not a bug. Treat gate.reason as an opaque string: log it, and do not branch on it.It appears routinely on the text auto-split path, where under np-2026-08-r4 a response can carry gate.reason: "standdown:source_support_missing" alongside outcome: "engaged". A client that branches on gate.engaged will read a successful selection as an empty one and throw the saving away.gate.engaged predates outcome, which is why older integrations reach for it. It is still published, and it is not the result.gate.signals is informational and is not the response’s record counts. gate.signals.n_candidates can differ from records_available; the two are not meant to reconcile.
Two more traps in the same area:
  • safety is null on np-2026-07-r2’s plain fixed-budget path. np-2026-08-r3, np-2026-08-r4 and np-2026-10-r6 return it populated on the same fixed-budget path (each was observed doing so on a live capture, on requests that neither set mode: "adaptive" nor require_evidence_coverage), so under the default you will see safety populated far more often. Either way, a null safety means the response carries no safety summary, never that the selection was sound.
  • An empty selected[] is not proof that nothing was selected. It is also empty when you sent return_per_record: false. Read records_selected.

Security screening outcomes

Pin np-2026-10-r7 or np-2026-10-r8, or send security_mode on any operating point, and the context the request delivers is screened for instruction-like text before it comes back. Two outcomes exist only when screening ran. Neither is an error, and both are a 200. security_mode is per request and available to any caller. Omit it and the request keeps the operating point’s setting: enforce on np-2026-10-r7 and np-2026-10-r8, and off on every other listed operating point. Send it and it replaces that setting for this request only, so security_mode: "off" on either label delivers the selection unscreened and the receipt carries no screening keys. sanitized_fallback: you receive a rendered packet with the flagged spans removed. The engine stood down on selection, so it would ordinarily have handed your context back; with screening on, it hands back that context with every flagged span replaced, in place, by the marker [QUARANTINED: instruction-like candidate text removed]. records_selected is 0, fallback_used is false, and rendered_context is the delivery. Send rendered_context, not your own copy of the packet.
blocked: no context is delivered. Under enforce, every span that remained after removal was flagged, so nothing of yours is returned. records_selected is 0, selected[] is empty, and rendered_context is a placeholder line that carries none of your content.
The marker is the only text screening ever writes into what you receive; it appears inside rendered_context, and inside a selected record’s excerpt when a flagged span was removed from a record that was otherwise kept. Everything else is your content. What the published clients do. A sanitized_fallback is applied: applied is true and rendered_context is what goes downstream. A blocked result is treated as a decline, exactly like a stand-down: applied is false and your original context goes downstream unchanged. The outcome is on the response for you to read, log or alarm on, and the request is charged like any other call that ran. If you want blocked to stop the model call, branch on outcome in your own code; the clients will not do it for you. After a catch on np-2026-10-r8. Every response that follows a caught attack is engaged with the span replaced, sanitized_fallback, or blocked: fallback_used is false, and the outcome is never stood_down or escalated, including when the context you sent is larger than the capacity it had to fit inside. Apply rendered_context on the first two, as the published clients do. A blocked result is the one case where the published clients continue with your original context, as described above. The receipt keys. Present in format_metrics only when screening ran. On np-2026-10-r7 and np-2026-10-r8 that is every response unless the request sends security_mode: "off"; a request with nothing to flag reads security_action: "keep" with security_findings_count: 0. Each record may carry source_kind: one of system, developer, user, tool, web, email, file, memory, retrieval, unknown. It is provenance for the receipt. It never changes what is screened or how, and no value exempts a record.

An engine failure returns 200, not 5xx

If the selection pass itself raises, you get a 200 with selection_error populated and a usable full-context body, because the correct client behaviour in that case is to send your original context, and the body already contains it.
This decides how you monitor us. An availability metric defined on “non-5xx” will report perfect health straight through an engine outage. Alarm on selection_error being non-null, not on status code.
A 500 is reserved for the cases where no response could be produced at all. Two shapes, and they want different handling:
  • {"error": "engine_error", …} is ours, and it means something inside the service failed in a way that produced no response. Nothing a caller can put in a request is supposed to reach it: a value we do not serve is rejected as a 400 before the engine runs (see below).
  • No error key: the platform’s, produced when something in front of the service fails.
Both are worth one or two retries with capped exponential backoff, and then stop. A 500 that survives two attempts on an unchanged request is not going to clear on the third.

Rejected values are a 400, not a 500

If the body parses but carries a value we do not serve, the request is rejected before the engine runs. That means a 400, no usage block, and no charge.
Never retry these. A 400 is a permanent client error: the same request fails identically every time, and retrying it on a backoff only spends your rate limit. Fix the request. Read /v1/operating-points for valid labels, and the record kinds for valid kinds.
One size ceiling is a 400, not a 413, and only on np-2026-08-r4. A text request whose paragraphs normalize to more than 1,000 units is rejected outright under np-2026-08-r4:
np-2026-07-r2 and np-2026-08-r3 do not reject this request at all: both silently cap it at 1,000 units and return 200, and on np-2026-08-r3 the engine can then escalate on the truncated input. See the operating-point comparison on the limits page for all three, verified live.
These two used to be 500 engine_error, and this page used to say so. They are 400s now. If you built alarming or retry logic on the old behaviour, a 500 from these causes is no longer something to expect, and the retry loop that used to be harmless is now pointless.

Timeouts and fail-open

Set a client timeout you are happy to add to your critical path. Every response reports the server-measured selection time as engine_latency_ms; your round trip adds network transit on top of it, and the two are different measurements that should not be added together casually. Measure both from your own client before you choose a timeout; a 10-second default is generous for either. The published Python client records its own round trip as format_metrics.client_latency_ms. That key is added by the client, not by the server: do not read it as a server measurement, and do not expect it on a response you fetched yourself. Whatever the failure (timeout, non-2xx, unparseable body, empty selection), send your original context unmodified. Selection is an optimisation, and a failed optimisation degrades to “no optimisation”, never to an empty prompt. There is a worked implementation in the quickstart.