Tawa docs

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:

request body
{ "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 treat needs_review as a hold: the action is already final.
  • Treat a missing needs_review as false and a missing engine_action as equal to action. Both are absent on a gateway that predates the review flag, where action is the engine's raw label and can still be REVIEW.
  • Accept unknown fields and unknown action values rather than failing on them.
  • Retrieval scans return aggregate.needs_review and aggregate.review_chunk_ids next to worst_action and flagged_chunk_ids.
  • Live events keep action as the engine's label and add needs_review and enforced_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" }
StatusMeaningRetry?
400Malformed request bodyNo
401Missing or invalid API keyNo
429Rate limitedYes — honor Retry-After
5xxServer errorYes — 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.

On this page