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.
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
bun add @kansato/whistle-react @kansato/whistle-sdk
Quick setup
1. Create the route handler
Create app/api/whistle/report/route.ts:
import { createWhistleHandler } from "@kansato/whistle-react/next";
export const { POST } = createWhistleHandler({
apiKey: process.env.WHISTLE_API_KEY!,
projectId: process.env.WHISTLE_PROJECT_ID!,
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.
WHISTLE_API_KEY=wh_xxxxxxxxxxxx # Secret key — server-side only
WHISTLE_PROJECT_ID=your-project-id
Never expose WHISTLE_API_KEY to the client. Use secret keys (wh_) server-side only.
3. Wrap with WhistleProvider
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):
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:
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.
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.
const { submit, loading } = useReportSubmission();
useWhistle()
Low-level hook for opening the hosted report dialog programmatically.
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.).
<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 | Yes | — | Secret API key (wh_...). Never use publishable keys here. |
projectId | string | Yes | — | 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:
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:
export const { POST } = createWhistleHandler({
apiKey: process.env.WHISTLE_API_KEY!,
projectId: process.env.WHISTLE_PROJECT_ID!,
// No getReporter -- all reports are anonymous
});
Attach platform-specific context to every report:
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
interface IngestSubject {
type: string; // e.g., "user"
externalId: string; // Your external ID
display?: {
name?: string;
username?: string;
avatarUrl?: string;
};
metadata?: Record<string, unknown>;
}
ReportTarget
interface ReportTarget {
contentExternalId: string; // Your content ID
contentType: string; // e.g., "post", "comment", "message"
}
type ReportReasonInput =
| string // Single reason: "spam"
| string[] // Multiple reasons: ["spam", "harassment"]
| { names: string[] }; // Object format: { names: ["spam"] }
API keys
| Key prefix | Scope | Used by |
|---|
whpk_ | Client-side: open hosted forms, list reasons | WhistleProvider (browser) |
wh_ | 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:
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:
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",
});
- Need keys and auth details? See Developer access.
- Need server-side ingestion? See Node SDK.
- Need a non-React frontend? Use the hosted iframe snippet from Settings > Developer.