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

# Handle Stripe Radar reviews with Flex

> Step-by-step guide to wiring up review webhooks, building an internal review queue, and testing the flow end-to-end.

This guide walks you through everything you need to do to handle Stripe Radar fraud reviews through Flex — from subscribing to webhooks, to building an internal review queue, to testing the integration end-to-end.

By the end of this guide, you will be able to:

* React in real time when Radar opens a review on one of your charges.
* Take the right action when a review is closed (approved, refunded, fraud, disputed).
* Build a "Reviews queue" view for your fraud-ops team using the Flex REST API.
* Join reviews back to your own order IDs without maintaining an extra mapping.
* Verify the entire flow against test mode before shipping to production.

<Info>
  **What is a review?** A review is a Stripe Radar fraud-review record surfaced through Flex. When Radar flags one of your charges — either via a Radar rule or a manual review action in Stripe — Flex persists the review and notifies you via a webhook event and via a queryable record on the Flex REST API. A review is **open** while the decision is pending, and **closed** once it has been resolved (approved, refunded, marked as fraud, disputed, or redacted).
</Info>

## Before you begin

Make sure you have:

* A Flex API key with the `reviews:read` scope. Add it to an existing key in the Flex dashboard, or include it when minting a new key.
* A webhook endpoint registered with Flex that can receive `review.opened` and `review.closed` events.
* (Recommended) `client_reference_id` set on your `POST /v1/checkout/sessions` calls — this is the single highest-leverage change you can make for joining reviews back to your internal order IDs.

<Note>
  Reviews are **read-only** in Flex. Approving a review must be done in the Stripe dashboard — there is no `POST /v1/reviews/:id/approve` endpoint.
</Note>

## How the flow works

Before wiring anything up, here is the lifecycle you are integrating against:

<Steps>
  <Step title="Stripe Radar opens a review">
    Triggered by a Radar rule firing, or by a teammate opening a manual review in the Stripe dashboard.
  </Step>

  <Step title="Stripe fires `review.opened`">
    The event flows to Flex via Stripe's webhook pipeline.
  </Step>

  <Step title="Flex resolves the charge and persists the review">
    Flex assigns a `frv_…` ID and stores the record.
  </Step>

  <Step title="You receive the event in two places">
    A `review.opened` webhook is forwarded to your endpoint, and `GET /v1/reviews` immediately returns the new record.
  </Step>

  <Step title="The review is resolved in Stripe">
    Someone refunds, approves, accepts the dispute, or redacts the review.
  </Step>

  <Step title="Stripe fires `review.closed`">
    Flex updates the record: `open=false` and `closed_reason` is set.
  </Step>

  <Step title="You receive the close">
    A `review.closed` webhook is forwarded, and `GET /v1/reviews/:id` reflects the new state.
  </Step>
</Steps>

## Step 1 — Subscribe to the webhook events

In your Flex webhook configuration, subscribe to:

* `review.opened`
* `review.closed`

<Warning>
  **Watch the payload shape.** The review fields are nested under `object.review`, **not** flattened directly under `object`. Your handler should read `object.review.review_id`, `object.review.open`, etc. The wrapper key (`review`) corresponds to the `event_type` family.
</Warning>

A `review.opened` payload looks like this:

```json review.opened theme={null}
{
  "event_id": "fevt_01jx3q8kt1b9a7n2c4d5e6f7g8",
  "event_type": "review.opened",
  "event_dt": 1770000000,
  "object": {
    "review": {
      "review_id": "frv_01jx3q8kt1b9a7n2c4d5e6f7g8",
      "charge_id": "fch_01jx3q6ab1c7d2e3f4g5h6j7k8",
      "payment_intent_id": "fpi_01jx3q7ab1c8d2e3f4g5h6j7k8",
      "partner_id": "facct_b8b56d3f3ba09d6c5dc61f7916e03ae3",
      "reason": "rule",
      "opened_reason": "rule",
      "open": true,
      "billing_zip": "94103",
      "ip_address": "203.0.113.42",
      "client_reference_id": "order_12345",
      "test_mode": false,
      "created_at": "2026-04-29T10:20:00Z"
    }
  }
}
```

A `review.closed` payload adds `closed_reason` and flips `open` to `false`:

```json review.closed theme={null}
{
  "event_id": "fevt_01jx3q9ab1c2d3e4f5g6h7j8k9",
  "event_type": "review.closed",
  "event_dt": 1770003600,
  "object": {
    "review": {
      "review_id": "frv_01jx3q8kt1b9a7n2c4d5e6f7g8",
      "charge_id": "fch_01jx3q6ab1c7d2e3f4g5h6j7k8",
      "payment_intent_id": "fpi_01jx3q7ab1c8d2e3f4g5h6j7k8",
      "partner_id": "facct_b8b56d3f3ba09d6c5dc61f7916e03ae3",
      "reason": "refunded_as_fraud",
      "opened_reason": "rule",
      "closed_reason": "refunded_as_fraud",
      "open": false,
      "billing_zip": "94103",
      "ip_address": "203.0.113.42",
      "client_reference_id": "order_12345",
      "test_mode": false,
      "created_at": "2026-04-29T10:20:00Z"
    }
  }
}
```

### Verify the webhook signature

Flex signs every outbound webhook so you can confirm it actually came from Flex. The canonical delivery path uses **Svix** and includes `svix-id`, `svix-timestamp`, and `svix-signature` headers — see the [Verifying Webhooks](/developer-guides/webhooks/verifying-webhooks) guide for the full verification flow and SDK examples.

<Warning>
  **Legacy webhook path** — Partners on Flex's legacy webhook path will see `flex-signature`, `flex-event-id`, and `flex-timestamp` headers instead. This path is deprecated and being removed once all partners are migrated to Svix; new integrations should target the Svix flow.
</Warning>

### Make your handler idempotent

Stripe and Flex both retry webhooks. Your handler should be safe to invoke twice with the same payload.

| Scenario                                                        | What to expect                                                                                       |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Same event delivered twice                                      | Be idempotent on `event_id`.                                                                         |
| `review.opened` retried after `review.closed` already processed | Closed state is preserved. `open` stays `false`, `closed_reason` retained.                           |
| Late-arriving event with stale data                             | `partner_id`, `charge_id`, `payment_intent_id`, and `created_at` are write-once and not overwritten. |

## Step 2 — Handle `review.opened`

When a review opens, the charge is still captured but Stripe (or your own ops team) has flagged it for human review. You usually want to **pause downstream side effects** until the review resolves.

A typical handler:

1. Look up the order in your system using `object.review.client_reference_id` (or fall back to `payment_intent_id`).
2. If the order has not yet shipped or been provisioned, hold fulfilment until the review closes.
3. Optionally surface the review in your internal admin tool so an ops person can investigate.

<Tip>
  If you set `client_reference_id` on the underlying checkout session, Flex hydrates it onto the review automatically — you do not need a separate `payment_intent_id` → order lookup.
</Tip>

## Step 3 — Handle `review.closed`

When you receive a `review.closed`, branch on `closed_reason` and take the matching action:

| `closed_reason`     | What it means                                                              | What to do                                                                                   |
| ------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `approved`          | The charge was reviewed and approved. Funds remain captured.               | Release any held fulfilment and continue normally.                                           |
| `refunded`          | The charge was refunded as part of closing the review.                     | Cancel the order in your system if you have not already.                                     |
| `refunded_as_fraud` | Refunded and explicitly marked as fraud. Feeds back into Radar's ML model. | Cancel the order, and consider flagging the customer / IP / device.                          |
| `disputed`          | The cardholder disputed the charge before the review was manually closed.  | Switch to the dispute response workflow — see the [Disputes API guide](/disputes-api-guide). |
| `redacted`          | Rare — typically a data-removal request.                                   | No customer-facing action; remove related records if relevant.                               |

<Warning>
  **Closed reviews stay closed.** If a `review.opened` retry arrives **after** Flex has already processed `review.closed`, the row stays closed. `open` does not flip back to `true`, and `closed_reason` is preserved. Treat your own internal state the same way.
</Warning>

## Step 4 — Build a "Reviews queue" view

Webhooks tell you what just happened, but for an ops dashboard you also want to ask "what is open right now?" Use the REST API for this.

### Authenticate

| Requirement    | Detail                      |
| -------------- | --------------------------- |
| Auth type      | Bearer token (Flex API key) |
| Required scope | `reviews:read`              |

### List open reviews

Poll for everything currently in review:

```bash theme={null}
curl -X GET "https://api.withflex.com/v1/reviews?open=true&limit=100" \
  -H "Authorization: Bearer fsk_live_..."
```

Render the response so your fraud-ops team can:

* See which orders are currently in review.
* Click through to the corresponding charge / order in your admin tools.
* Identify reviews that have been open unusually long.

Pair this with `GET /v1/reviews?open=false` filtered by date for a historical audit log.

### Paginate through the results

Reviews are returned newest-first as a flat array — there is no `has_more` field.

```bash theme={null}
# First page
curl "https://api.withflex.com/v1/reviews?open=true&limit=100" \
  -H "Authorization: Bearer fsk_live_..."

# Next page — pass the review_id of the LAST item from the previous response
curl "https://api.withflex.com/v1/reviews?open=true&limit=100&starting_after=frv_01jx..." \
  -H "Authorization: Bearer fsk_live_..."
```

If you receive fewer than `limit` items, you have reached the end. Use `ending_before` for backward pagination.

### Available filters

| Parameter             | Type    | Use it to…                                                                 |
| --------------------- | ------- | -------------------------------------------------------------------------- |
| `open`                | boolean | Return only open (`true`) or only closed (`false`) reviews. Omit for both. |
| `charge_id`           | string  | Filter to reviews on a specific Flex charge.                               |
| `payment_intent_id`   | string  | Filter to reviews on a specific Flex payment intent.                       |
| `client_reference_id` | string  | Filter to reviews tied to one of your order IDs.                           |
| `starting_after`      | string  | Forward pagination cursor (a `review_id`).                                 |
| `ending_before`       | string  | Backward pagination cursor.                                                |
| `limit`               | integer | Page size, between `1` and `100`. Default `20`.                            |

### Audit a single review

When you need to look up one review by Flex ID:

```bash theme={null}
curl -X GET "https://api.withflex.com/v1/reviews/frv_01jx3q8kt1b9a7n2c4d5e6f7g8" \
  -H "Authorization: Bearer fsk_live_..."
```

```json Response theme={null}
{
  "review": {
    "review_id": "frv_01jx3q8kt1b9a7n2c4d5e6f7g8",
    "charge_id": "fch_01jx...",
    "payment_intent_id": "fpi_01jx...",
    "partner_id": "facct_...",
    "reason": "refunded_as_fraud",
    "opened_reason": "rule",
    "closed_reason": "refunded_as_fraud",
    "open": false,
    "billing_zip": "94103",
    "ip_address": "203.0.113.42",
    "client_reference_id": "order_12345",
    "test_mode": false,
    "created_at": "2026-04-29T10:20:00Z"
  }
}
```

### Handle errors

| Status             | When                                                                                                                                                           |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401 Unauthorized` | API key is missing or lacks the `reviews:read` scope.                                                                                                          |
| `404 Not Found`    | Review does not exist, **or** belongs to another partner, **or** `test_mode` does not match the API key being used. (Cross-partner existence is never leaked.) |

## Step 5 — Join reviews to your order IDs

The cleanest way to tie a review back to your own system is via `client_reference_id`. Set it on the checkout session when you create it:

```bash theme={null}
curl -X POST "https://api.withflex.com/v1/checkout/sessions" \
  -H "Authorization: Bearer fsk_live_..." \
  -d "client_reference_id=order_12345" \
  ...
```

Flex hydrates that value onto every review for the resulting charge. To pull every review (open or closed) tied to a given order:

```bash theme={null}
curl -X GET "https://api.withflex.com/v1/reviews?client_reference_id=order_12345" \
  -H "Authorization: Bearer fsk_live_..."
```

<Tip>
  If you already set `client_reference_id` on every checkout session (most integrations do), you are done — no extra mapping table required. If you do not, adding it is the single highest-leverage change for working with reviews.
</Tip>

## Step 6 — Test the integration end-to-end

<Note>
  Stripe does not open reviews on standard test card payments. Use one of the methods below to generate a review event end-to-end.
</Note>

### Method A — `stripe trigger`

If you have the Stripe CLI authenticated against your Stripe test account, fire a synthetic event:

```bash theme={null}
stripe trigger review.opened
```

This is the same path Stripe production webhooks take, so it exercises the full Flex flow: signature verification → charge resolution → DB write → outbound `review.opened` to your endpoint.

Then verify the review landed in Flex:

```bash theme={null}
curl -s "https://api.withflex.com/v1/reviews?open=true&limit=5" \
  -H "Authorization: Bearer fsk_test_..." | jq
```

You should see the new review with a fresh `frv_…` ID.

### Method B — Synthetic webhook (local development)

For local development against `localhost`, send a signed synthetic Stripe webhook directly to your Flex server. The event differences for Reviews:

| Field         | Value                                                                                                          |
| ------------- | -------------------------------------------------------------------------------------------------------------- |
| `type`        | `"review.opened"` (or `"review.closed"`)                                                                       |
| `data.object` | A Stripe `review` object (`id` starts with `prv_`, includes `charge`, `reason`, `opened_reason`, `open`, etc.) |

Example `data.object` for a `review.opened`:

```json theme={null}
{
  "id": "prv_synthetic_001",
  "object": "review",
  "billing_zip": "94103",
  "charge": "ch_...",
  "closed_reason": null,
  "created": 1770000000,
  "ip_address": "203.0.113.42",
  "livemode": false,
  "open": true,
  "opened_reason": "rule",
  "payment_intent": "pi_...",
  "reason": "rule"
}
```

For `review.closed`, set `open: false`, `reason: "refunded_as_fraud"`, and `closed_reason: "refunded_as_fraud"`. Keep the same `id` so Flex upserts onto the existing record rather than creating a new one.

### Verification checklist

Walk through these scenarios before considering your integration done:

| Scenario                                                    | Expected outcome                                                                                 |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `review.opened` received                                    | New `frv_…` review in DB; webhook forwarded with `object.review.open: true`                      |
| `client_reference_id` set on checkout session               | Appears in `object.review.client_reference_id` on the forwarded webhook                          |
| `review.closed` received                                    | Same `frv_…` review updated; `object.review.open: false`, `closed_reason` set; webhook forwarded |
| Out-of-order: `review.opened` retried after `review.closed` | DB row stays closed; `open` does not flip back to `true`                                         |
| Duplicate `review.opened` (same `prv_` ID)                  | No duplicate row — idempotent upsert                                                             |
| `GET /v1/reviews?open=true` after `review.closed`           | Closed review is no longer in the response                                                       |
| `GET /v1/reviews/frv_…` for another partner's review        | `404 Not Found`                                                                                  |
| API key without `reviews:read` scope                        | `401 Unauthorized` on `/v1/reviews`                                                              |
| Test-mode key reading a live review (or vice-versa)         | `404 Not Found`                                                                                  |

## Reference

### The review object

```json theme={null}
{
  "review_id": "frv_01jx3q8kt1b9a7n2c4d5e6f7g8",
  "charge_id": "fch_01jx3q6ab1c7d2e3f4g5h6j7k8",
  "payment_intent_id": "fpi_01jx3q7ab1c8d2e3f4g5h6j7k8",
  "partner_id": "facct_b8b56d3f3ba09d6c5dc61f7916e03ae3",
  "reason": "rule",
  "opened_reason": "rule",
  "open": true,
  "billing_zip": "94103",
  "ip_address": "203.0.113.42",
  "client_reference_id": "order_12345",
  "test_mode": false,
  "created_at": "2026-04-29T10:20:00Z"
}
```

<ResponseField name="review_id" type="string">
  Flex review ID (`frv_` prefix). Use this in REST calls.
</ResponseField>

<ResponseField name="charge_id" type="string | null">
  Flex charge ID (`fch_`) for the reviewed transaction.
</ResponseField>

<ResponseField name="payment_intent_id" type="string | null">
  Flex payment intent ID (`fpi_`) linked to the charge.
</ResponseField>

<ResponseField name="partner_id" type="string">
  Flex partner that owns the review.
</ResponseField>

<ResponseField name="reason" type="string">
  Current high-level reason. Tracks `opened_reason` while open; tracks `closed_reason` once closed.
</ResponseField>

<ResponseField name="opened_reason" type="string">
  Why the review was opened. One of `rule`, `manual`.
</ResponseField>

<ResponseField name="closed_reason" type="string | omitted">
  Why the review was closed. Present only when `open: false`. One of `approved`, `disputed`, `redacted`, `refunded`, `refunded_as_fraud`.
</ResponseField>

<ResponseField name="open" type="boolean">
  `true` while the review is pending. `false` once resolved.
</ResponseField>

<ResponseField name="billing_zip" type="string | null">
  Billing ZIP captured at payment, when present.
</ResponseField>

<ResponseField name="ip_address" type="string | null">
  IP address captured at payment, when present.
</ResponseField>

<ResponseField name="client_reference_id" type="string | null">
  The `client_reference_id` you set on the underlying checkout session.
</ResponseField>

<ResponseField name="test_mode" type="boolean">
  `true` for test-mode reviews.
</ResponseField>

<ResponseField name="created_at" type="string">
  ISO 8601 timestamp when Flex first persisted the review.
</ResponseField>

### Reason codes

**`opened_reason`**

| Value    | Meaning                                                          |
| -------- | ---------------------------------------------------------------- |
| `rule`   | A Radar rule fired and opened the review automatically.          |
| `manual` | A teammate opened the review manually from the Stripe dashboard. |

**`closed_reason`** (set once `open: false`)

| Value               | Meaning                                                                                |
| ------------------- | -------------------------------------------------------------------------------------- |
| `approved`          | The charge was reviewed and approved. Funds remain captured.                           |
| `refunded`          | The charge was refunded as part of closing the review.                                 |
| `refunded_as_fraud` | Refunded and explicitly marked as fraud — feeds back into Radar's ML model.            |
| `disputed`          | The cardholder disputed the charge (chargeback) before the review was manually closed. |
| `redacted`          | The review was redacted (rare; e.g. data-removal request).                             |

### Reviews vs. Early Fraud Warnings

A single charge may have **both** a review and an EFW. They are independent signals — subscribe to both for full visibility.

|                           | Review                                                          | Early Fraud Warning                               |
| ------------------------- | --------------------------------------------------------------- | ------------------------------------------------- |
| **Trigger**               | Radar rule fires, or a teammate opens a manual review in Stripe | Card issuer files a fraud report with the network |
| **State model**           | `open` → `closed` (with `closed_reason`)                        | `actionable: true` → `actionable: false`          |
| **Implies a chargeback?** | No — Stripe-internal flag for human review                      | No, but is a stronger signal one may follow       |
| **REST API**              | `GET /v1/reviews`, `GET /v1/reviews/:id`                        | None (webhook only)                               |
| **Webhook events**        | `review.opened`, `review.closed`                                | `radar.early_fraud_warning.created` / `.updated`  |
| **Required auth scope**   | `reviews:read`                                                  | n/a                                               |

### What is *not* forwarded

* Stripe reviews that have **no associated charge** are silently dropped (no DB write, no webhook). This is rare but possible during certain failure modes.
* Reviews on charges belonging to partners not onboarded to Flex are not forwarded.
