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

# React SDK

> Use the Whistle React SDK to embed secure, server-validated report flows into your Next.js application.

The `@kansato/whistle-react` package provides a React SDK for embedding Whistle report forms with **server-side reporter validation**. Reports are proxied through your Next.js server, preventing client-side spoofing of reporter information.

## Install

```bash theme={null}
bun add @kansato/whistle-react @kansato/whistle-sdk
```

## Quick setup

### 1. Create the route handler

Create `app/api/whistle/report/route.ts`:

```tsx theme={null}
import { createWhistleHandler } from "@kansato/whistle-react/next";

export const { POST } = createWhistleHandler({
  getReporter: (request) => {
    const session = getSession(request);
    if (!session?.user) return null;
    return {
      type: "user",
      externalId: session.user.id,
      display: {
        name: session.user.name,
        username: session.user.username,
      },
    };
  },
});

export const runtime = "edge"; // optional
```

No file copying. No global variables.

### 2. Configure environment variables

```env theme={null}
WHISTLE_API_KEY=sk_live_xxxxxxxxxxxx  # Secret key — server-side only
WHISTLE_PROJECT_ID=your-project-id
```

Never expose `WHISTLE_API_KEY` to the client. Use secret keys (`sk_live_`) server-side only.

### 3. Wrap with `WhistleProvider`

```tsx theme={null}
import { WhistleProvider } from "@kansato/whistle-react";

export default function RootLayout({ children }) {
  return (
    <WhistleProvider
      apiKey={process.env.NEXT_PUBLIC_WHISTLE_PUBLISHABLE_KEY}
      projectId={process.env.NEXT_PUBLIC_WHISTLE_PROJECT_ID}
      routeHandlerPath="/api/whistle/report"
    >
      {children}
    </WhistleProvider>
  );
}
```

### 4. Use the component or hook

**Component (recommended):**

```tsx theme={null}
import { ReportDialog } from "@kansato/whistle-react";

function PostActions({ post }) {
  return (
    <ReportDialog
      subject={{ type: "user", externalId: post.authorId }}
      target={{ contentExternalId: post.id, contentType: "post" }}
      onSuccess={(report) => console.log("Report submitted:", report.id)}
    />
  );
}
```

**Hook:**

```tsx theme={null}
import { useSecureReportSubmission } from "@kansato/whistle-react";

function ReportButton() {
  const { submit, loading } = useSecureReportSubmission({
    onSuccess: (response) => console.log("Reported:", response.report.id),
  });

  return (
    <button
      onClick={() =>
        submit({
          subject: { type: "user", externalId: "user-123" },
          target: { contentExternalId: "post-456", contentType: "post" },
          reason: "spam",
          description: "This is spam content",
          // No reporter — attached automatically by server
        })
      }
      disabled={loading}
    >
      {loading ? "Submitting..." : "Report"}
    </button>
  );
}
```

## Components

### `WhistleProvider`

| Prop               | Type      | Default                 | Description                                       |
| ------------------ | --------- | ----------------------- | ------------------------------------------------- |
| `apiKey`           | `string`  | required                | Publishable API key for client-side features.     |
| `projectId`        | `string`  | required                | Project UUID.                                     |
| `baseUrl`          | `string`  | production SDK API      | Override the Whistle SDK API base URL.            |
| `timeout`          | `number`  | `30000`                 | Request timeout for non-UI SDK calls in ms.       |
| `branding`         | `boolean` | `true`                  | Show "Powered by Kansato" inside the hosted form. |
| `routeHandlerPath` | `string`  | `"/api/whistle/report"` | Path to your Next.js route handler.               |

### `ReportDialog`

Ready-to-use report dialog component with secure submission.

| Prop           | Type                                  | Description                                                            |
| -------------- | ------------------------------------- | ---------------------------------------------------------------------- |
| `subject`      | `IngestSubject`                       | The user who created the content.                                      |
| `target`       | `ReportTarget`                        | The content being reported.                                            |
| `reasons`      | `ReportReasonOption[]`                | Optional reason list override.                                         |
| `trigger`      | `ReactNode`                           | Custom trigger element.                                                |
| `variant`      | `"dialog" \| "inline"`                | Modal or inline iframe flow.                                           |
| `onSuccess`    | `(response) => void`                  | Called after successful submission.                                    |
| `onError`      | `(error) => void`                     | Called if submission fails.                                            |
| `onSubmit`     | `(data, { resolve, reject }) => void` | **Controlled mode**: receive form data, call resolve/reject when done. |
| `open`         | `boolean`                             | Controlled open state.                                                 |
| `onOpenChange` | `(open) => void`                      | Open state callback.                                                   |

## Hooks

### `useSecureReportSubmission` (recommended)

Submits reports through your platform's server. Reporter information is attached server-side automatically.

```tsx theme={null}
const { submit, loading, error } = useSecureReportSubmission({
  onSuccess: (response) => console.log("Reported:", response.report.id),
  onError: (error) => console.error("Error:", error),
});
```

### `useReportSubmission` (legacy)

Direct API submission using publishable keys. Less secure -- only use if you cannot implement a server-side proxy.

```tsx theme={null}
const { submit, loading } = useReportSubmission();
```

### `useWhistle()`

Low-level hook for opening the hosted report dialog programmatically.

```tsx theme={null}
const { openReport, closeReport } = useWhistle();

<button onClick={() =>
  openReport({
    subject: { type: "user", externalId: authorId },
    target: { contentExternalId: postId, contentType: "post" },
  })
}>
  Report
</button>
```

## Controlled submission

By default, the report form iframe POSTs directly to your route handler. Pass an `onSubmit` prop to intercept form data and submit it yourself (server action, custom fetch, etc.).

```tsx theme={null}
<ReportDialog
  subject={{ type: "user", externalId: "user-123" }}
  target={{ contentExternalId: "post-456", contentType: "post" }}
  onSubmit={(data, { resolve, reject }) => {
    // data = { subject, target, reason: string[], description?: string }
    // No reporter field -- you attach it server-side
    submitReport(data)
      .then((result) => resolve(result))
      .catch(reject);
  }}
/>
```

Available on `ReportDialog`, `ReportForm`, and `ReportFrame`. Its presence enables controlled mode -- no extra config needed.

### Direct vs controlled

|                    | **Direct (default)**                                       | **Controlled (`onSubmit`)**                              |
| ------------------ | ---------------------------------------------------------- | -------------------------------------------------------- |
| **Flow**           | User -> Iframe -> `POST /api/whistle/report` -> Triage API | User -> Iframe -> `postMessage` -> your code -> anywhere |
| **Config**         | Set up route handler with `createWhistleHandler`           | Pass `onSubmit` prop                                     |
| **Who submits**    | The iframe calls `fetch()` internally                      | Your callback handles submission                         |
| **Reporter info**  | Attached server-side by your route handler                 | You attach it in your submit logic                       |
| **Error handling** | Iframe shows errors from route handler / Triage API        | You call `reject(error)` and the iframe displays it      |

Use controlled mode when you need **server actions**, custom endpoints, pre-processing, multi-step confirmation flows, or offline queuing.

### `onSubmit` data shape

| Field              | Type                  | Description                                                |
| ------------------ | --------------------- | ---------------------------------------------------------- |
| `data.subject`     | `IngestSubject`       | Subject from init context                                  |
| `data.target`      | `ReportTarget`        | Target from init context                                   |
| `data.reason`      | `string[]`            | Selected reason values                                     |
| `data.description` | `string \| undefined` | Optional description text                                  |
| `resolve(result?)` | function              | Call on success. Result shown in iframe success view.      |
| `reject(error?)`   | function              | Call on failure. Error message shown in iframe error view. |

## Handler configuration

### Options

| Option              | Type                                     | Required | Default                          | Description                                                           |
| ------------------- | ---------------------------------------- | -------- | -------------------------------- | --------------------------------------------------------------------- |
| `apiKey`            | `string`                                 | No       | `process.env.WHISTLE_API_KEY`    | Secret API key (`sk_live_...`). Never use publishable keys here.      |
| `projectId`         | `string`                                 | No       | `process.env.WHISTLE_PROJECT_ID` | Your project UUID.                                                    |
| `getReporter`       | `(req) => SecureReporterContext \| null` | No       | Anonymous (IP-based)             | Resolve the authenticated user. Return `null` for anonymous fallback. |
| `apiUrl`            | `string`                                 | No       | Production URL                   | Custom Triage API base URL.                                           |
| `getReportMetadata` | `(req, body) => Record<string, unknown>` | No       | `{ userAgent, ip, timestamp }`   | Attach custom metadata to every report.                               |

### `getReporter` -- Auth provider examples

Pass a `getReporter` function to attach user identity server-side. The client cannot spoof this.

**Type signature:**

```typescript theme={null}
type GetReporterHook = (
  request: NextRequest,
) => SecureReporterContext | null;

interface SecureReporterContext {
  type: string;           // e.g., "user", "moderator"
  externalId: string;     // Your platform's user ID
  display?: {
    name?: string | null;
    username?: string | null;
    avatarUrl?: string | null;
  };
  metadata?: Record<string, unknown>;
}
```

| Provider          | `getReporter` implementation                                                                                                                                                                                                                                                                                                                                              |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **NextAuth**      | `tsx\ngetReporter: async (request) => {\n  const token = await getToken({ req: request });\n  if (!token?.sub) return null;\n  return {\n    type: "user",\n    externalId: token.sub,\n    display: {\n      name: token.name as string ?? null,\n      username: token.email as string ?? null,\n      avatarUrl: token.picture as string ?? null,\n    },\n  };\n},\n` |
| **Clerk**         | `tsx\ngetReporter: (request) => {\n  const { userId } = getAuth(request);\n  if (!userId) return null;\n  return { type: "user", externalId: userId };\n},\n`                                                                                                                                                                                                             |
| **Custom cookie** | `tsx\ngetReporter: (request) => {\n  const userId = request.cookies.get("user_id")?.value;\n  if (!userId) return null;\n  return { type: "user", externalId: userId };\n},\n`                                                                                                                                                                                            |

### Anonymous reporting

Omit `getReporter`. Reports are submitted with an anonymous reporter derived from request IP:

```tsx theme={null}
export const { POST } = createWhistleHandler({
  // No getReporter -- all reports are anonymous
});
```

### `getReportMetadata` -- Custom metadata

Attach platform-specific context to every report:

```tsx theme={null}
getReportMetadata: (request, body) => ({
  userAgent: request.headers.get("user-agent"),
  ip: request.headers.get("x-forwarded-for") ?? "unknown",
  platformVersion: "1.0.0",
  contentType: body.target.contentType,
}),
```

Default metadata includes `userAgent`, `ip`, and `timestamp` when omitted.

### Edge runtime

Add `export const runtime = "edge"` to your route file. The handler uses only Web APIs and is fully edge-compatible. Omit for Node.js runtime (default).

## Types

### `IngestSubject`

```typescript theme={null}
interface IngestSubject {
  type: string;           // e.g., "user"
  externalId: string;     // Your external ID
  display?: {
    name?: string;
    username?: string;
    avatarUrl?: string;
  };
  metadata?: Record<string, unknown>;
}
```

### `ReportTarget`

```typescript theme={null}
interface ReportTarget {
  contentExternalId: string;   // Your content ID
  contentType: string;         // e.g., "post", "comment", "message"
}
```

### `ReportReasonInput`

```typescript theme={null}
type ReportReasonInput =
  | string                    // Single reason: "spam"
  | string[]                  // Multiple reasons: ["spam", "harassment"]
  | { names: string[] };      // Object format: { names: ["spam"] }
```

## API keys

| Key prefix | Scope                                             | Used by                                |
| ---------- | ------------------------------------------------- | -------------------------------------- |
| `pk_live_` | Client-side: open hosted forms, list reasons      | `WhistleProvider` (browser)            |
| `sk_live_` | Server-side: create reports, moderation endpoints | `createWhistleHandler` (route handler) |

Create keys in **Settings > Developer > API Keys**. Never expose secret keys to the client.

## Migration from direct API

If you were using `useReportSubmission` with direct API calls:

**Before:**

```tsx theme={null}
const { submit } = useReportSubmission();
await submit({
  subject: { type: "user", externalId: "user-123" },
  target: { contentExternalId: "post-456", contentType: "post" },
  reporter: { type: "user", externalId: currentUser.id }, // Spoofable!
  reason: "spam",
});
```

**After:**

```tsx theme={null}
const { submit } = useSecureReportSubmission();
await submit({
  subject: { type: "user", externalId: "user-123" },
  target: { contentExternalId: "post-456", contentType: "post" },
  // No reporter -- attached securely by server
  reason: "spam",
});
```

## Related guides

* Need keys and auth details? See [Developer access](/developer-access).
* Need server-side ingestion? See [Node SDK](/sdk).
* Need a non-React frontend? Use the hosted iframe snippet from **Settings > Developer**.
