> ## 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.

# Handle revocations

> Receive revocations, stop work under them, purge caches, delete provider copies, and acknowledge before the deadline.

<Note>
  Planned protocol reference. The revocation feed, webhook delivery, acknowledgements and
  license lifecycle mutations below are not served by the current Talent API sandbox.
  Start with the [runnable quickstart](/quickstart) and [sandbox support matrix](/sandbox).
</Note>

A talent can narrow consent at any time. Every narrowing reaches you as one **revocation** object:
it says what is cut, what you must do, and by when you must confirm you did it. Reflections stops
new checks itself. Your job is everything it cannot reach: your caches, copies at your model
providers, and outputs already released.

```mermaid theme={null}
sequenceDiagram
    participant T as Talent
    participant R as Reflections
    participant P as Your platform
    T->>R: Revoke license
    R->>R: Deny new checks and held outputs
    R-->>P: revocation.created
    P->>R: GET /v1/revocations?after=cursor
    P->>P: Stop, purge caches, delete provider copies
    P->>R: Acknowledge by acknowledge_by
    P->>P: Apply post_revocation
    P->>R: Acknowledge withdraw_outputs by withdraw_by
    R-->>T: Acknowledged, on the record
```

## What a revocation contains

```json theme={null}
{
  "id": "rvk_s9t0wxyhk5zba2q8v1m3n7p4r6",
  "object": "revocation",
  "livemode": true,
  "scope": "license",
  "targets": {
    "talent": "tal_9t0wxyhk5zba2q8v1m3n7p4r6s",
    "licenses": ["lic_ba2q8v1m3n7p4r6s9t0wxyhk5z"],
    "facets": ["fct_v1m3n7p4r6s9t0wxyhk5zba2q8"],
    "mandate": null
  },
  "status": "pending_acknowledgement",
  "initiated_by": "talent",
  "for_cause": false,
  "cause": null,
  "note": null,
  "effective_at": "2026-10-17T18:03:40Z",
  "post_revocation": { "policy": "retain", "withdraw_by": null },
  "required_actions": ["stop_generation", "purge_cached_decisions", "delete_provider_copies"],
  "provider_copies": [
    { "facet": "fct_v1m3n7p4r6s9t0wxyhk5zba2q8", "provider": "example-voice-provider", "external_id": "vx_4f9a2c" }
  ],
  "affected": { "checks_denied": 3, "generations_denied": 0, "sessions_terminated": 1, "released_outputs": 418 },
  "acknowledge_by": "2026-10-18T18:03:40Z",
  "acknowledgements": [],
  "created_at": "2026-10-17T18:03:40Z"
}
```

Here Maya Okafor revoked Northlight's platform license for her speaking voice. `scope` says what
narrowed:

| `scope` | What changed |
| - | - |
| `license` | One license ended. |
| `facet` | A facet was withdrawn, which narrows every license that covers it. |
| `mandate` | A representative's authority ended, and any licenses in `targets.licenses` with it. |
| `talent` | The talent withdrew consent everywhere. |

At `effective_at`, Reflections has already denied pending checks with `license_revoked`, denied held
outputs with `license_revoked` (each one sends
[`generation.denied`](/api-reference/webhooks/generation-denied), and can never be released), and
terminated live sessions. `affected` counts what it cut.
Authorizations already issued expire within 15 minutes and session tokens within 5. Do not use
them in the meantime: `stop_generation` covers them.

## Receive the webhook

[`revocation.created`](/api-reference/webhooks/revocation-created) is the fast path, sent within 5
seconds (p95) of `effective_at`. Verify its signature, record its ID, queue the work and return a
`2xx` within 10 seconds. Do the work outside the request.

Deliveries are signed with the [Standard Webhooks](https://www.standardwebhooks.com) scheme: an
HMAC-SHA256, keyed with your endpoint secret (the base64 after `whsec_`), over
`webhook-id.webhook-timestamp.body`. Each `v1,<base64>` entry in `webhook-signature` is a
candidate; more than one appears while a secret rotates.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const SECRET = process.env.REFLECTIONS_WEBHOOK_SECRET!; // whsec_…
  const TOLERANCE_SECONDS = 5 * 60;

  export function verifyWebhook(headers: Headers, body: string): boolean {
    const id = headers.get("webhook-id");
    const timestamp = headers.get("webhook-timestamp");
    const signatures = headers.get("webhook-signature");
    if (!id || !timestamp || !signatures) return false;

    const sentAt = Number(timestamp);
    if (!Number.isInteger(sentAt)) return false;
    if (Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) return false;

    const key = Buffer.from(SECRET.slice("whsec_".length), "base64");
    const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();

    return signatures.split(" ").some((entry) => {
      const [version, signature] = entry.split(",");
      if (version !== "v1" || !signature) return false;
      const received = Buffer.from(signature, "base64");
      return received.length === expected.length && timingSafeEqual(received, expected);
    });
  }

  // A Fetch API handler. Read the raw body before parsing it.
  export async function handleWebhook(req: Request): Promise<Response> {
    const body = await req.text();
    if (!verifyWebhook(req.headers, body)) return new Response("invalid signature", { status: 400 });

    const event = JSON.parse(body);
    if (await seen(event.id)) return new Response(null, { status: 204 }); // duplicate delivery

    if (event.type === "revocation.created") await queue.push("revocation", event.data.object.id);
    await markSeen(event.id);
    return new Response(null, { status: 204 });
  }
  ```

  ```python Python theme={null}
  import base64, hashlib, hmac, os, time
  from flask import Flask, request

  SECRET = os.environ["REFLECTIONS_WEBHOOK_SECRET"]  # whsec_…
  TOLERANCE_SECONDS = 5 * 60

  def verify_webhook(headers, body: bytes) -> bool:
      msg_id = headers.get("webhook-id")
      timestamp = headers.get("webhook-timestamp")
      signatures = headers.get("webhook-signature")
      if not (msg_id and timestamp and signatures):
          return False
      try:
          if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
              return False
      except ValueError:
          return False

      key = base64.b64decode(SECRET.removeprefix("whsec_"))
      signed = f"{msg_id}.{timestamp}.".encode() + body
      expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

      for entry in signatures.split(" "):
          version, _, signature = entry.partition(",")
          if version == "v1" and hmac.compare_digest(signature, expected):
              return True
      return False

  app = Flask(__name__)

  @app.post("/webhooks/reflections")
  def reflections_webhook():
      body = request.get_data()  # the raw body, before parsing
      if not verify_webhook(request.headers, body):
          return "invalid signature", 400

      event = request.get_json()
      if seen(event["id"]):
          return "", 204  # duplicate delivery

      if event["type"] == "revocation.created":
          queue.push("revocation", event["data"]["object"]["id"])
      mark_seen(event["id"])
      return "", 204
  ```
</CodeGroup>

`seen`, `markSeen` and `queue` stand for your own storage. Webhooks arrive at least once and in no
guaranteed order: deduplicate on the event `id` (equal to `webhook-id`), and ignore an event whose
`sequence` is lower than one you already processed for the same object. Any Standard Webhooks
library verifies these signatures too.

## Read the feed

The webhook is the fast path. The feed, [`GET /v1/revocations`](/api-reference/revocations/list-revocations),
is the source of truth. If you cache decisions, read it at least every 60 seconds, and on every
`revocation.created`. Pass `after` with the last revocation ID you finished to read everything
since, oldest first.

<CodeGroup>
  ```bash cURL theme={null}
  curl -G https://app.reflections.ai/v1/revocations \
    -H "Authorization: Bearer rk_test_…" \
    -H "Reflections-Version: 2026-10-15" \
    --data-urlencode "after=rvk_m2p4r6t8w0y2a4c6e8g0j2m4p6" \
    --data-urlencode "limit=100"
  ```

  ```typescript TypeScript theme={null}
  const API = "https://app.reflections.ai/v1";
  const headers = {
    Authorization: `Bearer ${process.env.REFLECTIONS_KEY}`,
    "Reflections-Version": "2026-10-15",
  };

  async function list(params: Record<string, string>) {
    const url = new URL(`${API}/revocations`);
    for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
    const res = await fetch(url, { headers });
    if (!res.ok) throw new Error(`List revocations failed: ${res.status}`); // retry on the next run
    return res.json();
  }

  // No cursor yet: start at the newest revocation, and apply every one still open.
  async function firstRun() {
    const newest: string | undefined = (await list({ limit: "1" })).data[0]?.id;
    for (const status of ["pending_acknowledgement", "overdue"]) {
      let next: string | null = null;
      do {
        const params: Record<string, string> = { status, limit: "100" };
        if (next) params.cursor = next;
        const page = await list(params); // newest first
        for (const revocation of page.data) await applyRevocation(revocation);
        next = page.next_cursor;
      } while (next);
    }
    if (newest) await cursor.set(newest);
    return newest;
  }

  export async function syncRevocations() {
    let after = (await cursor.get()) ?? (await firstRun()); // the newest revocation you finished
    while (after) {
      const page = await list({ after, limit: "100" }); // oldest first
      for (const revocation of page.data) {
        await applyRevocation(revocation);
        after = revocation.id;
        await cursor.set(after);
      }
      if (!page.has_more) return;
    }
  }
  ```

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

  API = "https://app.reflections.ai/v1"
  headers = {
      "Authorization": f"Bearer {os.environ['REFLECTIONS_KEY']}",
      "Reflections-Version": "2026-10-15",
  }

  def list_revocations(params):
      res = requests.get(f"{API}/revocations", headers=headers, params=params)
      res.raise_for_status()  # retry on the next run
      return res.json()

  def first_run():
      """No cursor yet: start at the newest revocation, and apply every one still open."""
      latest = list_revocations({"limit": 1})["data"]
      newest = latest[0]["id"] if latest else None
      for status in ("pending_acknowledgement", "overdue"):
          params = {"status": status, "limit": 100}
          while True:
              page = list_revocations(params)  # newest first
              for revocation in page["data"]:
                  apply_revocation(revocation)
              if not page["next_cursor"]:
                  break
              params["cursor"] = page["next_cursor"]
      if newest:
          cursor.set(newest)
      return newest

  def sync_revocations():
      after = cursor.get() or first_run()  # the newest revocation you finished
      while after:
          page = list_revocations({"after": after, "limit": 100})  # oldest first
          for revocation in page["data"]:
              apply_revocation(revocation)
              after = revocation["id"]
              cursor.set(after)
          if not page["has_more"]:
              return
  ```
</CodeGroup>

On your first run you have no cursor. Read the newest revocation's ID, then page through
`status=pending_acknowledgement` and `status=overdue` with `cursor` and apply each one; an overdue
revocation still needs its actions and acknowledgement. Then store that ID as your cursor. From
then on, read with `after`, which lists oldest first. Store the cursor durably, and advance it only
after a revocation is fully applied.

## Apply the required actions

Do every action in `required_actions` for everything under `targets`:

| Action | What to do |
| - | - |
| `stop_generation` | Start nothing new for the target licenses, facets or talent. Cancel queued jobs that have not reached the model. |
| `purge_cached_decisions` | Drop cached checks, authorizations and reuse windows that name a target. |
| `delete_provider_copies` | Delete every copy in `provider_copies` at its provider, such as a voice by its `external_id`. Record when each was deleted. |
| `cease_distribution` | Place released outputs nowhere new. Existing placements may stay. |
| `withdraw_outputs` | Take released outputs down by `post_revocation.withdraw_by`. |

`post_revocation.policy` says what happens to outputs you already released:

| Policy | Released outputs |
| - | - |
| `retain` | Stay usable within their original scope and window. |
| `cease_distribution` | No new placements after `effective_at`. |
| `withdraw` | Taken down by `withdraw_by`. Only a revocation with `for_cause: true` carries it. |

Withdraw a released output you took down with
[`POST /v1/generations/{generation_id}/withdraw`](/api-reference/generations/withdraw-a-generation),
so its record shows it is no longer in use.

## Acknowledge

Attest what you did with
[`POST /v1/revocations/{revocation_id}/acknowledgements`](/api-reference/revocations/acknowledge-a-revocation),
in one call or two:

1. By `acknowledge_by`, 24 hours after `effective_at`, acknowledge every action in
   `required_actions` except `withdraw_outputs`, and every provider copy you deleted. Do not wait
   for withdrawals to finish before you send it.
2. If `required_actions` includes `withdraw_outputs`, acknowledge it in a later call, once the
   outputs are down, by `post_revocation.withdraw_by`.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://app.reflections.ai/v1/revocations/rvk_s9t0wxyhk5zba2q8v1m3n7p4r6/acknowledgements \
    -H "Authorization: Bearer rk_test_…" \
    -H "Reflections-Version: 2026-10-15" \
    -H "Idempotency-Key: ack-rvk_s9t0wxyhk5zba2q8v1m3n7p4r6" \
    -H "Content-Type: application/json" \
    -d '{
      "actions": ["stop_generation", "purge_cached_decisions", "delete_provider_copies"],
      "deleted_copies": [
        { "provider": "example-voice-provider", "external_id": "vx_4f9a2c", "deleted_at": "2026-10-17T18:09:12Z" }
      ],
      "note": "Voice deleted at the provider. Reuse caches purged."
    }'
  ```

  ```typescript TypeScript theme={null}
  async function acknowledge(revocationId: string, key: string, body: object) {
    const res = await fetch(`${API}/revocations/${revocationId}/acknowledgements`, {
      method: "POST",
      headers: { ...headers, "Idempotency-Key": key, "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (!res.ok) throw new Error(`Acknowledgement failed: ${res.status}`); // retry with the same key
  }

  // By acknowledge_by. deletedCopies comes from your own record, so a retry sends the same body.
  export async function acknowledgeActions(revocation: any, deletedCopies: object[]) {
    await acknowledge(revocation.id, `ack-${revocation.id}`, {
      actions: revocation.required_actions.filter((a: string) => a !== "withdraw_outputs"),
      deleted_copies: deletedCopies,
      note: "Voice deleted at the provider. Reuse caches purged.",
    });
  }

  // Later, by post_revocation.withdraw_by, once every released output is down.
  export async function acknowledgeWithdrawal(revocation: any) {
    await acknowledge(revocation.id, `ack-${revocation.id}-withdraw`, { actions: ["withdraw_outputs"] });
  }
  ```

  ```python Python theme={null}
  def acknowledge(revocation_id, key, body):
      res = requests.post(
          f"{API}/revocations/{revocation_id}/acknowledgements",
          headers={**headers, "Idempotency-Key": key},
          json=body,
      )
      res.raise_for_status()  # retry with the same key

  # By acknowledge_by. deleted_copies comes from your own record, so a retry sends the same body.
  def acknowledge_actions(revocation, deleted_copies):
      acknowledge(revocation["id"], f"ack-{revocation['id']}", {
          "actions": [a for a in revocation["required_actions"] if a != "withdraw_outputs"],
          "deleted_copies": deleted_copies,
          "note": "Voice deleted at the provider. Reuse caches purged.",
      })

  # Later, by post_revocation.withdraw_by, once every released output is down.
  def acknowledge_withdrawal(revocation):
      acknowledge(revocation["id"], f"ack-{revocation['id']}-withdraw", {"actions": ["withdraw_outputs"]})
  ```
</CodeGroup>

Each call has its own `Idempotency-Key`, because its body differs, and adds an entry to
`acknowledgements`. The revocation stays `pending_acknowledgement` until every required action is
acknowledged, then becomes `acknowledged`. Acknowledgements are shown on the talent's record.

If either deadline passes first, `acknowledge_by` for the first call or `withdraw_by` for
`withdraw_outputs`, the revocation becomes `overdue`, you receive
[`revocation.overdue`](/api-reference/webhooks/revocation-overdue), and it is shown as overdue on the
talent's record and to your account contact.

## What can go wrong

| Problem | What to do |
| - | - |
| Your endpoint was down | The feed has every revocation. Missed events stay in [`GET /v1/events`](/api-reference/events/list-events) for 30 days. |
| A provider deletion fails | Retry it. Do not list `delete_provider_copies` until every copy is gone. Contact [support](/production/support) before `acknowledge_by` if you cannot finish. |
| You receive `license.revoked` | A `revocation.created` is sent at the same time. Act on that one. |
| You were waiting on a held output | It ends `denied` with `license_revoked`, sent as [`generation.denied`](/api-reference/webhooks/generation-denied). Never deliver it. |
| You receive `license.suspended` | A suspension is not a revocation: there is no revocation object and nothing to acknowledge. Until `license.resumed`, checks deny with `license_suspended`, open sessions end, and renewals fail with `409 session_not_active`. Drop cached decisions under the license. Held outputs stay held; released outputs are unaffected. |

## Planned test coverage

The proposed [license-revocation helper](/api-reference/test-helpers/revoke-a-license) would
exercise revocation, suspension, resumption and withdrawal. That helper, its events and stream
messages are not served by the current sandbox. Test your handler with local fixtures until
these operations are listed as available in the [sandbox](/sandbox).

## Related

<Columns cols={2}>
  <Card title="Revocation" icon="ban" href="/concepts/revocation">
    What revocation stops, what it leaves, and how fast.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/api-reference/webhooks/overview">
    Signing, retries, ordering and replay.
  </Card>

  <Card title="Caching and outages" icon="bolt" href="/build/caching-and-outages">
    What you may cache, and for how long.
  </Card>

  <Card title="Revocation in the Standard" icon="book" href="/standard/revocation">
    The deadlines a conformant platform meets.
  </Card>
</Columns>


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