> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reflections.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The OpenAPI document at /api-reference/openapi.yaml defines the target contract. Its x-reflections-availability field and each operation's availability notice identify supported test and live forms. Do not infer implementation from a schema or code sample. Live keys support account details, reads of the licenses the calling account holds as licensee or issued as talent (GET /v1/licenses?role=issuer), and GET /v1/generations?license=… for the state of outputs under such a license (summaries only: no prompt, reference, bytes or metadata). Live image checks, submissions and receipts are built but not activated: until activation, a live POST /v1/checks or /v1/generations returns 403 live_not_activated, which must not be retried. Discovery and JWKS are at the origin root (https://app.reflections.ai/.well-known/...), not under /v1.
> Three rules hold everywhere: a decision is never an error (deny and escalate are 200 responses, and a live check still evaluating is a 202 with status checking); generate nothing for a named talent without an allow; deliver nothing until the generation is allow.
> The sandbox accepts personal rk_test_ keys. Persist a separate Idempotency-Key and body for each logical /v1 POST; retry an unknown outcome with the same key and body. OAuth and Talent API MCP remain planned.

# Caching and outages

> Reuse decisions safely, retry without duplicating work, and fail closed for named talent when Reflections is unavailable.

<Note>
  Protocol reference. Reuse and idempotency can be tested against fictional fixtures. The name
  index, revocation feed, license events and production availability targets below are not
  implemented by the current sandbox.
  Start with the [runnable quickstart](/quickstart) and [sandbox support matrix](/sandbox).
</Note>

This page covers what you may cache, how to retry, and what to do when a call fails. Two rules
decide everything on it: only an `allow` lets you generate a named talent, and nothing you cache
outlives a revocation.

## What you may cache

| What | For how long | Invalidate on |
| - | - | - |
| An `authorization` | Until `expires_at`, for at most `max_generations`, for exactly the request it covers | A revocation naming its license, facet or talent, or `license.suspended` for its license |
| A reuse window | Up to `reuse.ttl` (at most 15 minutes) and `reuse.max_generations` (at most 1,000) | A revocation, or `license.suspended` |
| The JWKS | Its `Cache-Control` max-age. Refetch once on an unknown `kid`. | An unknown `kid` |
| The name index | Until its `expires_at`; refresh at least hourly with `ETag` | `talent.updated` |
| Talent, facets and licenses you read | Minutes, for display | `talent.updated`, `facet.updated`, `license.*` events |
| A receipt's JWS | Forever. It never changes. | Never. `use_status` is live; read it fresh. |

Platforms that cache any decision read the [revocation feed](/build/handling-revocations) at least
every 60 seconds, in addition to webhooks.

A suspension is not a revocation and is not in the feed: there is no revocation object and nothing
to acknowledge. When you receive
[`license.suspended`](/api-reference/webhooks/license-suspended), drop every cached decision under
that license. Until `license.resumed`, checks deny with `license_suspended`, open sessions end,
and renewals fail with `409 session_not_active`.
Held outputs stay held, and outputs already released are unaffected.

## Reuse windows

Some products make many generations for one user in a few minutes: a text-to-speech app reading
lines, an image tool producing variations. Ask for a reuse window so one decision covers them:

<CodeGroup>
  ```bash cURL theme={null}
  curl https://app.reflections.ai/v1/checks \
    -H "Authorization: Bearer rk_test_…" \
    -H "Reflections-Version: 2026-10-15" \
    -H "Idempotency-Key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{
      "talent": ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
      "facet": "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
      "inputs": [{ "role": "script", "text": "Welcome back to the Ridge Runners podcast." }],
      "use": {
        "modality": "speech", "kind": "impersonation", "purpose": "personal",
        "commercial": false, "channels": ["in_app"], "territories": ["US"]
      },
      "end_user": { "id": "usr_48213" },
      "execution": { "provider": "example-voice-provider", "model": "tts-2", "candidates": 1 },
      "on_escalate": "deny",
      "reuse": { "max_generations": 200, "ttl": "PT15M" }
    }'
  ```

  ```typescript TypeScript theme={null}
  const check = await fetch("https://app.reflections.ai/v1/checks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.REFLECTIONS_KEY}`,
      "Reflections-Version": "2026-10-15",
      "Idempotency-Key": crypto.randomUUID(),
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      talent: ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
      facet: "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
      inputs: [{ role: "script", text: "Welcome back to the Ridge Runners podcast." }],
      use: {
        modality: "speech", kind: "impersonation", purpose: "personal",
        commercial: false, channels: ["in_app"], territories: ["US"],
      },
      end_user: { id: "usr_48213" },
      execution: { provider: "example-voice-provider", model: "tts-2", candidates: 1 },
      on_escalate: "deny",
      reuse: { max_generations: 200, ttl: "PT15M" },
    }),
  }).then((r) => r.json());
  ```

  ```python Python theme={null}
  import os, uuid, requests

  check = requests.post(
      "https://app.reflections.ai/v1/checks",
      headers={
          "Authorization": f"Bearer {os.environ['REFLECTIONS_KEY']}",
          "Reflections-Version": "2026-10-15",
          "Idempotency-Key": str(uuid.uuid4()),
      },
      json={
          "talent": ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
          "facet": "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
          "inputs": [{"role": "script", "text": "Welcome back to the Ridge Runners podcast."}],
          "use": {
              "modality": "speech", "kind": "impersonation", "purpose": "personal",
              "commercial": False, "channels": ["in_app"], "territories": ["US"],
          },
          "end_user": {"id": "usr_48213"},
          "execution": {"provider": "example-voice-provider", "model": "tts-2", "candidates": 1},
          "on_escalate": "deny",
          "reuse": {"max_generations": 200, "ttl": "PT15M"},
      },
  ).json()
  ```
</CodeGroup>

The window covers later generations of the same `use`, for the same talent, model and `end_user`,
until `authorization.expires_at` or until `authorization.generations_used` reaches
`max_generations`. Every output in the window is still submitted as a generation, where its words
or pixels are checked against the license. A new user, a new use or a new model needs a new check.

## Never turn a non-allow into an allow

Caching goes wrong when something that is not an `allow` is treated as one.

* An error, a timeout or a `503` is not a decision. Generate nothing for a named talent.
* A cached `deny` stays a `deny` for that request. When it ages out of your cache, check again; do
  not assume the answer changed.
* `escalate` is not an `allow`. Wait for [`check.resolved`](/build/handling-escalations).
* A resolution with `status: none`, or no local match in the name index, is not permission. Only a
  check resolves indirect references, such as "the runner from Ridgeline's spring campaign".
* An `allow` covers its own request. A cached authorization never covers a different prompt,
  reference, model or use, unless it carries a reuse window for that use.

## Retry safely

Every `POST` takes an `Idempotency-Key`, kept for 24 hours. Generate one key per logical request,
store it with the request, and reuse it on every retry.

| Response | What happened | What to do |
| - | - | - |
| The original response again | Same key, same body: a replay | Use it. Nothing ran twice. |
| `409 idempotency_key_in_use` | The first request with this key is still running | Wait briefly and retry with the same key. |
| `422 idempotency_key_reused` | Same key, different body | A bug: a changed request needs a new key. |
| `429 rate_limited` | Over your rate limit | Wait for `Retry-After`, then retry with the same key. |
| `500 internal_error` | Failed on our side | Retry with the same key and backoff. |
| `503 service_unavailable` | Temporarily unavailable | Retry with the same key and backoff. Fail closed meanwhile. |
| A timeout or network error | Unknown outcome | Retry with the same key, never a new one. |

<CodeGroup>
  ```typescript TypeScript theme={null}
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

  export async function post(path: string, body: unknown, key = crypto.randomUUID()) {
    for (let attempt = 0; attempt < 6; attempt++) {
      let wait = Math.min(1000 * 2 ** attempt, 30_000) * (0.5 + Math.random() / 2);
      let res: Response | undefined;
      try {
        res = await fetch(`https://app.reflections.ai/v1${path}`, {
          method: "POST",
          headers: {
            Authorization: `Bearer ${process.env.REFLECTIONS_KEY}`,
            "Reflections-Version": "2026-10-15",
            "Idempotency-Key": key, // the same key on every attempt
            "Content-Type": "application/json",
          },
          body: JSON.stringify(body),
          signal: AbortSignal.timeout(10_000),
        });
      } catch {
        // Timeout or network error: the outcome is unknown. Retry with the same key.
      }
      if (res?.ok) {
        const result = await res.json();
        // A live check still evaluating returns 202 "checking": not final, retry with the same key.
        if (res.status === 202 && result.object === "check" && result.status === "checking") {
          await sleep(retryAfterMs(res) ?? 1000);
          continue;
        }
        return result;
      }
      if (res) {
        const problem = await res.json().catch(() => ({})); // a proxy's 502 may not be JSON
        const retryable =
          res.status >= 500 || res.status === 429 || problem.code === "idempotency_key_in_use";
        if (!retryable) throw new Error(`${res.status} ${problem.code}: ${problem.detail ?? problem.title}`);
        wait = retryAfterMs(res) ?? wait;
      }
      await sleep(wait);
    }
    throw new Error("Reflections unavailable: fail closed");
  }

  // Retry-After from Reflections is in seconds; a proxy in front of it may send an HTTP date.
  function retryAfterMs(res: Response): number | undefined {
    const value = res.headers.get("Retry-After");
    if (!value) return undefined;
    const seconds = Number(value);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
    const date = Date.parse(value);
    return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
  }
  ```

  ```python Python theme={null}
  import os, random, time, uuid, requests
  from email.utils import parsedate_to_datetime

  def post(path, body, key=None):
      key = key or str(uuid.uuid4())  # the same key on every attempt
      for attempt in range(6):
          wait = min(2 ** attempt, 30) * (0.5 + random.random() / 2)
          try:
              res = requests.post(
                  f"https://app.reflections.ai/v1{path}",
                  headers={
                      "Authorization": f"Bearer {os.environ['REFLECTIONS_KEY']}",
                      "Reflections-Version": "2026-10-15",
                      "Idempotency-Key": key,
                  },
                  json=body,
                  timeout=10,
              )
          except requests.RequestException:
              time.sleep(wait)  # unknown outcome: retry with the same key
              continue
          if res.ok:
              result = res.json()
              # A live check still evaluating returns 202 "checking": not final, retry with the same key.
              if res.status_code == 202 and result.get("object") == "check" and result.get("status") == "checking":
                  delay = retry_after(res)
                  time.sleep(1 if delay is None else delay)
                  continue
              return result
          try:
              problem = res.json()
          except ValueError:  # a proxy's 502 may not be JSON
              problem = {}
          retryable = res.status_code >= 500 or res.status_code == 429 or problem.get("code") == "idempotency_key_in_use"
          if not retryable:
              raise RuntimeError(f"{res.status_code} {problem.get('code')}: {problem.get('detail', problem.get('title'))}")
          delay = retry_after(res)
          time.sleep(wait if delay is None else delay)
      raise RuntimeError("Reflections unavailable: fail closed")

  def retry_after(res):
      """Seconds to wait, or None. Reflections sends seconds; a proxy may send an HTTP date."""
      value = res.headers.get("Retry-After")
      if not value:
          return None
      try:
          return max(0.0, float(value))
      except ValueError:
          pass
      try:
          return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
      except (TypeError, ValueError):
          return None
  ```
</CodeGroup>

A `deny` or `escalate` is a `200` with a decision, not an error. Never retry a decision to get a
different one.

## When Reflections is unavailable

**Named talent: fail closed.** If a check cannot complete for a request that names a talent in
`talent`, matches the name index, or carries a reference or voice sample, do not generate. The one
exception is an authorization you already hold: while it has not expired and has generations left,
you may use it for the request it covers. Tell your user the request cannot run right now; do not
fall back to generating without a decision.

**Requests that name and match no one: you may proceed.** A request that names no talent, matches no
entry in your current [name index](/build/resolving-talent#match-locally-with-the-name-index), and
carries no reference or voice sample may be generated and delivered while the check cannot complete.
Nothing is sent later. With no index, or one past its `expires_at`, every request fails closed.

**Live sessions** may continue until the current session token expires, at most 5 minutes, never
past it. See [Real-time sessions](/build/real-time-sessions).

**Revocations** wait for you. When calls succeed again, read the feed from your stored cursor, and
any `license.suspended` events you missed with
[`GET /v1/events`](/api-reference/events/list-events), before you serve any cached decision. The
acknowledgement deadlines do not move: acknowledge every action but `withdraw_outputs` by
`acknowledge_by`, and `withdraw_outputs` by `post_revocation.withdraw_by`.

The proposed production availability target for checks is 99.95% per month. It is not a confirmed
service commitment or a measured sandbox result. Current status and incidents are on
[status.reflections.ai](https://status.reflections.ai).

## When evaluation is unavailable

Sometimes Reflections is up but cannot finish evaluating a rule. The check then returns `escalate`
with `escalation.reason: "evaluation_unavailable"`, or `deny` with `review_required` if you sent
`on_escalate: "deny"`. At the generation, a rule the talent wrote that could not be read is recorded
in `unchecked_terms` and the output is delivered; a protected attribute that could not be read
denies the output.

In test mode, `rfl_test_unavailable` in a check's text forces the `evaluation_unavailable` path.

## Related

<Columns cols={2}>
  <Card title="Failure modes" icon="triangle-exclamation" href="/concepts/failure-modes">
    Fail-closed and fail-open rules in one place.
  </Card>

  <Card title="Idempotency" icon="rotate" href="/api-reference/idempotency">
    Keys, replay, and the three outcomes.
  </Card>

  <Card title="Rate limits" icon="gauge" href="/api-reference/rate-limits">
    Limits, headers and raising them.
  </Card>

  <Card title="Handle revocations" icon="ban" href="/build/handling-revocations">
    The feed that invalidates your caches.
  </Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.