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

# Webhooks

> Planned webhook delivery, signature verification, retries, ordering and event replay.

<Note>
  Planned protocol reference. The current Talent API sandbox does not register webhook endpoints
  or deliver events. Use polling and the test escalation helper for supported sandbox
  transitions.
  Start with the [runnable quickstart](/quickstart) and [sandbox support matrix](/sandbox).
</Note>

The planned webhook service sends an event to your endpoints when something happens that you must act on: an
escalation is decided, a held output is released, a license is revoked. Events are signed with the
[Standard Webhooks](https://www.standardwebhooks.com) scheme, so any library that implements it
can verify them.

## Create an endpoint

<CodeGroup>
  ```bash cURL theme={null}
  curl https://app.reflections.ai/v1/webhook_endpoints \
    -H "Authorization: Bearer rk_test_…" \
    -H "Reflections-Version: 2026-10-15" \
    -H "Idempotency-Key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://northlight.example/webhooks/reflections",
      "events": ["*"],
      "connected": true,
      "description": "Production event handler"
    }'
  ```

  ```typescript TypeScript theme={null}
  const endpoint = await fetch("https://app.reflections.ai/v1/webhook_endpoints", {
    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({
      url: "https://northlight.example/webhooks/reflections",
      events: ["*"],
      connected: true,
      description: "Production event handler",
    }),
  }).then((r) => r.json());
  // Store endpoint.secret now. It is not shown again.
  ```

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

  endpoint = requests.post(
      "https://app.reflections.ai/v1/webhook_endpoints",
      headers={
          "Authorization": f"Bearer {os.environ['REFLECTIONS_KEY']}",
          "Reflections-Version": "2026-10-15",
          "Idempotency-Key": str(uuid.uuid4()),
      },
      json={
          "url": "https://northlight.example/webhooks/reflections",
          "events": ["*"],
          "connected": True,
          "description": "Production event handler",
      },
  ).json()
  # Store endpoint["secret"] now. It is not shown again.
  ```
</CodeGroup>

```json theme={null}
{
  "id": "we_m3n7p4r6s9t0wxyhk5zba2q8v1",
  "object": "webhook_endpoint",
  "livemode": false,
  "url": "https://northlight.example/webhooks/reflections",
  "events": ["*"],
  "status": "enabled",
  "api_version": "2026-10-15",
  "connected": true,
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw…",
  "created_at": "2026-10-17T18:00:00Z"
}
```

| Field | Meaning |
| - | - |
| `url` | An HTTPS URL. |
| `events` | The event types to send, or `["*"]` for every type, including types added later. |
| `api_version` | The version events are rendered in. Defaults to your account's version and cannot be changed later. |
| `connected` | Also send events about connected licensees' objects. |
| `secret` | `whsec_…`, returned only when the endpoint is created or its secret is rotated. |

Endpoints created with a test key receive only test events, and endpoints created with a live key
only live events.

## The event

Each delivery is a `POST` with one event as its JSON body:

```json theme={null}
{
  "id": "evt_8v1m3n7p4r6s9t0wxyhk5zba2q",
  "object": "event",
  "type": "generation.released",
  "api_version": "2026-10-15",
  "livemode": true,
  "account": "acct_2q8v1m3n7p4r6s9t0wxyhk5zba",
  "sequence": 3,
  "created_at": "2026-10-17T19:42:10Z",
  "data": {
    "object": {
      "id": "gen_6s9t0wxyhk5zba2q8v1m3n7p4r",
      "object": "generation",
      "check": "chk_3m7p4r6s9t0wxyhk5zba2q8v1n",
      "status": "released",
      "decision": "allow",
      "assurance": "observed",
      "receipt": "rcpt_bfwgfkw4w683avvxm1q0h4fard",
      "c2pa": {
        "label": "ai.reflections.license",
        "data": {
          "receipt": "eyJhbGciOiJFUzI1NiIsImtpZCI6InJmbC0yMDI2LTEwLWRvY3MzIiwidHlwIjoidGxwLXJlY2VpcHQrand0In0…",
          "verify_url": "https://app.reflections.ai/r/rcpt_bfwgfkw4w683avvxm1q0h4fard"
        }
      }
    },
    "previous_attributes": {
      "status": "held",
      "decision": "escalate",
      "receipt": null,
      "c2pa": null
    }
  }
}
```

| Field | Meaning |
| - | - |
| `id` | The event ID, `evt_…`. Equal to the `webhook-id` header. Use it to ignore duplicate deliveries. |
| `type` | The event type. See the [event types](#event-types). |
| `api_version` | The version `data.object` is rendered in. |
| `livemode` | `true` for live events. |
| `account` | The connected licensee the event concerns, or `null` when it concerns your own account. |
| `sequence` | Increases by one with each event about the same object. See [Ordering](#ordering). |
| `data.object` | The object as it was when the event happened. |
| `data.previous_attributes` | The fields that changed, with their old values. Present on events that change an object. |

## Verify the signature

Every delivery carries three headers:

| Header | Value |
| - | - |
| `webhook-id` | The event ID. |
| `webhook-timestamp` | Unix seconds when this delivery was signed. |
| `webhook-signature` | Space-separated `v1,<base64>` signatures. More than one appears while a secret rotates. |

The signature is HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`, keyed with your
secret: the base64 part after `whsec_`, decoded to bytes. To verify a delivery:

1. Reject it if `webhook-timestamp` is more than 5 minutes from your clock.
2. Compute the HMAC over the raw body, exactly as received. Do not parse and re-serialize it first.
3. Accept it if any `v1` entry in `webhook-signature` matches, using a constant-time comparison.

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

  const TOLERANCE_SECONDS = 5 * 60;

  export function verifyWebhook(
    secret: string, // "whsec_…"
    id: string, // webhook-id header
    timestamp: string, // webhook-timestamp header
    signature: string, // webhook-signature header
    body: string, // the raw request body
  ): boolean {
    const sent = Number(timestamp);
    if (!Number.isInteger(sent)) return false;
    if (Math.abs(Date.now() / 1000 - sent) > TOLERANCE_SECONDS) return false;

    const key = Buffer.from(secret.slice(secret.indexOf("_") + 1), "base64");
    const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();

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

  // For example, in a handler that receives a standard Request:
  export async function POST(req: Request): Promise<Response> {
    const body = await req.text();
    const valid = verifyWebhook(
      process.env.REFLECTIONS_WEBHOOK_SECRET ?? "",
      req.headers.get("webhook-id") ?? "",
      req.headers.get("webhook-timestamp") ?? "",
      req.headers.get("webhook-signature") ?? "",
      body,
    );
    if (!valid) return new Response("invalid signature", { status: 400 });

    const event = JSON.parse(body);
    await queue.push(event); // Process after you respond.
    return new Response(null, { status: 204 });
  }
  ```

  ```python Python theme={null}
  import base64, binascii, hashlib, hmac, time

  TOLERANCE_SECONDS = 5 * 60

  def verify_webhook(secret: str, msg_id: str, timestamp: str, signature: str, body: bytes) -> bool:
      """secret is "whsec_…"; body is the raw request body as bytes."""
      try:
          sent = int(timestamp)
      except ValueError:
          return False
      if abs(time.time() - sent) > TOLERANCE_SECONDS:
          return False

      key = base64.b64decode(secret.split("_", 1)[1])
      signed = f"{msg_id}.{timestamp}.".encode() + body
      expected = hmac.new(key, signed, hashlib.sha256).digest()

      for entry in signature.split(" "):
          version, _, value = entry.partition(",")
          if version != "v1" or not value:
              continue
          try:
              given = base64.b64decode(value, validate=True)
          except binascii.Error:
              continue
          if hmac.compare_digest(given, expected):
              return True
      return False

  # For example, in a Flask view:
  # body = request.get_data()
  # ok = verify_webhook(os.environ["REFLECTIONS_WEBHOOK_SECRET"],
  #                     request.headers["webhook-id"], request.headers["webhook-timestamp"],
  #                     request.headers["webhook-signature"], body)
  ```
</CodeGroup>

To rotate a secret, call
[`POST /v1/webhook_endpoints/{endpoint_id}/rotate_secret`](/api-reference/webhook-endpoints/rotate-a-webhook-secret).
For 24 hours, deliveries carry signatures from both the old and the new secret, so the function
above accepts either while you deploy the new one.

## Respond and retry

Return any `2xx` status within 10 seconds. Anything else, including a timeout or a redirect, is a
failure and the delivery is retried. Respond first and do the work afterwards, from a queue.

Delivery is at least once: the same event can arrive more than once. Record each `webhook-id` you
process and ignore repeats.

| Attempt | Delay after the previous attempt |
| - | - |
| 1 | Immediately |
| 2 | About 5 seconds |
| 3 | About 1 minute |
| 4 | About 5 minutes |
| 5 | About 30 minutes |
| 6 | About 2 hours |
| 7 | About 5 hours |
| 8 and later | Every 10 hours, until 72 hours after the event |

An endpoint that fails every delivery for 5 days is disabled. Reflections sends
[`webhook_endpoint.disabled`](/api-reference/webhooks/webhook-endpoint-disabled) to your other
endpoints and an email to your account's address. Fix the endpoint, re-enable it with
[`POST /v1/webhook_endpoints/{endpoint_id}`](/api-reference/webhook-endpoints/update-a-webhook-endpoint)
and `"status": "enabled"`, then [replay what you missed](#replay-missed-events).

## Ordering

Events can arrive in any order. Each event's `sequence` increases by one with each event about the
same object. Keep the highest `sequence` you processed for each object ID, and ignore an event whose
`sequence` is lower. To act on an object's current state rather than the state in the event,
retrieve the object.

## Replay missed events

Events are kept for 30 days. To catch up after an outage, read the
[events list](/api-reference/events/list-events) with `after` set to the last event you processed;
events come oldest first. Filter with `type`, such as `type=revocation.*`.

```bash theme={null}
curl -G https://app.reflections.ai/v1/events \
  -H "Authorization: Bearer rk_test_…" \
  -H "Reflections-Version: 2026-10-15" \
  -d after=evt_8v1m3n7p4r6s9t0wxyhk5zba2q \
  -d limit=100
```

To deliver one event again, call
[`POST /v1/events/{event_id}/resend`](/api-reference/events/resend-an-event), with `endpoint` to
send it to one endpoint only. A resent event has the same `id`, so your duplicate check still
applies.

<Warning>
  A missed `revocation.created` still binds you. If your endpoint was down, read the
  [revocation feed](/api-reference/revocations/list-revocations) as well as the events list before
  you generate again.
</Warning>

## Connected-account events

A platform that acts for licensees needs their events: a revocation of Ridgeline's license affects
the checks Northlight runs for Ridgeline. Create the endpoint with `connected: true`. Events about a
connected licensee's objects then carry the licensee's ID in `account`. Events about your own
objects carry `account: null`.

## Talent-side events

A representative receives events about the talents it represents, as their mandates allow:
`escalation.created` with `review_outputs`, `license_request.created` with `issue_licenses`, and
the `license.*`, `revocation.*` and `statement.*` events of those talents' licenses. These events
carry `account: null`. The talent is `data.object.talent`, or `data.object.targets.talent` on a
revocation. See
[Talent representatives](/guides/talent-representatives).

## Event types

| Event | Sent when |
| - | - |
| [`license.issued`](/api-reference/webhooks/license-issued) | The talent's side signed a license for the licensee. It waits for acceptance. |
| [`license.accepted`](/api-reference/webhooks/license-accepted) | The licensee accepted a license. |
| [`license.revised`](/api-reference/webhooks/license-revised) | The licensee accepted a new revision. It is in force. |
| [`license.revision_pending`](/api-reference/webhooks/license-revision-pending) | The talent's side signed a new revision. It waits for the licensee. |
| [`license.revision_declined`](/api-reference/webhooks/license-revision-declined) | The licensee declined a pending revision. |
| [`license.suspended`](/api-reference/webhooks/license-suspended) | The talent or a representative paused a license. |
| [`license.resumed`](/api-reference/webhooks/license-resumed) | A suspended license is active again. |
| [`license.revoked`](/api-reference/webhooks/license-revoked) | A license was revoked. Act on the `revocation.created` sent with it. |
| [`license.expired`](/api-reference/webhooks/license-expired) | A license window ended. |
| [`license.declined`](/api-reference/webhooks/license-declined) | The licensee declined a license before accepting it. |
| [`license_request.created`](/api-reference/webhooks/license-request-created) | A licensee asked a talent you represent for a license. |
| [`license_request.signed`](/api-reference/webhooks/license-request-signed) | The talent's side signed a license from your request. |
| [`license_request.declined`](/api-reference/webhooks/license-request-declined) | The talent's side declined your request. |
| [`license_draft.signed`](/api-reference/webhooks/license-draft-signed) | A draft you prepared was signed. |
| [`license_draft.declined`](/api-reference/webhooks/license-draft-declined) | The signer declined a draft you prepared. |
| [`check.resolved`](/api-reference/webhooks/check-resolved) | An escalated check was decided or expired. |
| [`escalation.created`](/api-reference/webhooks/escalation-created) | A check or held output waits for a decision from the talent's side. |
| [`generation.allowed`](/api-reference/webhooks/generation-allowed) | An output that was still being checked is allowed. |
| [`generation.released`](/api-reference/webhooks/generation-released) | A reviewer released a held output. |
| [`generation.denied`](/api-reference/webhooks/generation-denied) | An output was denied after an asynchronous check, or a reviewer declined it. |
| [`generation.expired`](/api-reference/webhooks/generation-expired) | A held output was not reviewed in time. |
| [`session.terminated`](/api-reference/webhooks/session-terminated) | Reflections ended a live session. |
| [`revocation.created`](/api-reference/webhooks/revocation-created) | Consent narrowed. Lists the actions you must take. |
| [`revocation.overdue`](/api-reference/webhooks/revocation-overdue) | A revocation was not acknowledged by `acknowledge_by`. |
| [`connection.created`](/api-reference/webhooks/connection-created) | A licensee connected your platform. |
| [`connection.revoked`](/api-reference/webhooks/connection-revoked) | A connection ended. |
| [`facet.updated`](/api-reference/webhooks/facet-updated) | A facet's material, status or provider bindings changed. |
| [`talent.updated`](/api-reference/webhooks/talent-updated) | A talent's names, aliases, posture or protection changed. |
| [`statement.issued`](/api-reference/webhooks/statement-issued) | A monthly statement was issued. |
| [`statement.disputed`](/api-reference/webhooks/statement-disputed) | The talent's side disputed a statement. |
| [`mandate.created`](/api-reference/webhooks/mandate-created) | A talent appointed your account as a representative. |
| [`mandate.ended`](/api-reference/webhooks/mandate-ended) | Your mandate for a talent ended. |
| [`webhook_endpoint.disabled`](/api-reference/webhooks/webhook-endpoint-disabled) | An endpoint failed for 5 days and was disabled. |

## Related

<Columns cols={2}>
  <Card title="Handle escalations" icon="bolt" href="/build/handling-escalations">
    `check.resolved`, `generation.released` and polling.
  </Card>

  <Card title="Handle revocations" icon="ban" href="/build/handling-revocations">
    The feed, purges, provider copies and acknowledgements.
  </Card>

  <Card title="Security" icon="lock" href="/production/security">
    Key handling, token verification and webhook signing.
  </Card>

  <Card title="Sandbox" icon="flask" href="/sandbox">
    Current operations and supported test transitions.
  </Card>
</Columns>


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