Best practices
Rate limits, retries, timeouts, and session grouping — for building your own client against the raw API.
Both official SDKs implement everything on this page for you already. If you're integrating in a language we don't have an SDK for, this is the behavior to replicate so your client behaves the same way under load, network failure, and rate limiting.
Rate limits
Requests are rate-limited per API key. When you exceed the limit, you get
back a 429:
{ "error": "Rate limit exceeded", "code": "RATE_LIMITED" }A 429 response includes a Retry-After header (seconds) when the server
can tell you exactly how long to wait — honor it when present, and only
fall back to your own backoff schedule when it's absent.
Retries
Retry 429 and 5xx responses, and network-level failures (connection
resets, timeouts) — not other 4xx responses, which mean the request
itself was invalid or unauthorized and won't succeed on retry.
Both SDKs retry up to 3 attempts by default, with exponential backoff and jitter between them. A minimal version of the same algorithm:
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
const retryable = isRetryable(err); // 429, 5xx, or network error
if (!retryable || attempt >= maxRetries) throw err;
const retryAfter = getRetryAfterSeconds(err); // from the response header, if present
const backoffMs = retryAfter
? retryAfter * 1000
: Math.min(1000 * 2 ** attempt, 30_000);
const jitterMs = Math.random() * backoffMs * 0.2;
await new Promise((resolve) => setTimeout(resolve, backoffMs + jitterMs));
}
}
}Don't retry indefinitely, and don't retry non-idempotent side effects blindly. A scan call is safe to retry (it has no side effects beyond logging), but if you've wrapped it in your own workflow with side effects downstream of the decision, make sure retrying the scan itself doesn't also re-trigger those.
Timeouts
Set a client-side request timeout — the API doesn't hold connections open
indefinitely, but a hung connection on your end can still block a request
thread or event-loop tick forever without one. Both SDKs default to
30 seconds; that's a reasonable starting point unless you're scanning
unusually large documents (see the SDKs' timeout option if you need to
tune this).
Session grouping
Pass a sessionId on every scan belonging to the same agent turn or
conversation (the prompt, any RAG lookups, tool calls, and the model's
reply) to correlate them:
{ "text": "…", "sessionId": "sess_a1b2c3" }This is what powers the live event stream and groups related scans in the dashboard's audit log — generate one per conversation/turn (a UUID is fine) and thread it through every scan call for that turn.
The decision
decision.action is ALLOW or BLOCK: act on it directly. decision.needs_review
(boolean) is true when the engine was uncertain, and decision.engine_action
is the engine's own label (ALLOW, REVIEW, BLOCK, HARD_BLOCK).
- Branch on
action. Do not treatneeds_reviewas a hold: the action is already final. - Treat a missing
needs_reviewasfalseand a missingengine_actionas equal toaction. Both are absent on a gateway that predates the review flag, whereactionis the engine's raw label and can still beREVIEW. - Accept unknown fields and unknown
actionvalues rather than failing on them. - Retrieval scans return
aggregate.needs_reviewandaggregate.review_chunk_idsnext toworst_actionandflagged_chunk_ids. - Live events keep
actionas the engine's label and addneeds_reviewandenforced_action.
Errors
Every error response is JSON with a human-readable error message and a
machine-readable code:
{ "error": "Invalid API key", "code": "INVALID_API_KEY" }| Status | Meaning | Retry? |
|---|---|---|
400 | Malformed request body | No |
401 | Missing or invalid API key | No |
429 | Rate limited | Yes — honor Retry-After |
5xx | Server error | Yes — backoff |
Network-level failures (connection refused, DNS failure, timeout) never
reach this shape at all — treat anything that isn't a parseable HTTP
response as retryable the same way you'd treat a 5xx.
Stability
Don't build on exact score thresholds
We continuously retrain and recalibrate the underlying detection models.
risk, confidence, and score_breakdown are useful diagnostic signal,
but their exact values and score_breakdown's shape can shift between
model versions; the same input scoring 0.87 today isn't guaranteed to
score 0.87 after a model update, even though its action will
typically stay consistent. Threshold on risk. Use confidence to
decide how much to trust a verdict. action stays the stable contract;
exact risk values can shift after recalibration. Treat
score_breakdown as opaque debug detail, not something to threshold
against directly.
The path-based version (/v1/...) covers the request/response
shape — fields won't be removed or repurposed within a version. It does
not freeze the detection model itself; accuracy and scoring improve
continuously within a version, which is precisely why the callout above
matters.
Language support
Which languages Tawa identifies and scans, and how everything else is handled.
Prometheus scrape endpoint GET
The org's current billing period as Prometheus text-exposition format — the native scrape target for Grafana's Prometheus data source and Datadog's OpenMetrics/Prometheus check. Always the current billing period; the only query parameter is `include_preview`.