Skip to main content

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.

2. Configure environment 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

PropTypeDefaultDescription
apiKeystringrequiredPublishable API key for client-side features.
projectIdstringrequiredProject UUID.
baseUrlstringproduction SDK APIOverride the Whistle SDK API base URL.
timeoutnumber30000Request timeout for non-UI SDK calls in ms.
brandingbooleantrueShow “Powered by Kansato” inside the hosted form.
routeHandlerPathstring"/api/whistle/report"Path to your Next.js route handler.

ReportDialog

Ready-to-use report dialog component with secure submission.
PropTypeDescription
subjectIngestSubjectThe user who created the content.
targetReportTargetThe content being reported.
reasonsReportReasonOption[]Optional reason list override.
triggerReactNodeCustom trigger element.
variant"dialog" | "inline"Modal or inline iframe flow.
onSuccess(response) => voidCalled after successful submission.
onError(error) => voidCalled if submission fails.
onSubmit(data, { resolve, reject }) => voidControlled mode: receive form data, call resolve/reject when done.
openbooleanControlled open state.
onOpenChange(open) => voidOpen state callback.

Hooks

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)
FlowUser -> Iframe -> POST /api/whistle/report -> Triage APIUser -> Iframe -> postMessage -> your code -> anywhere
ConfigSet up route handler with createWhistleHandlerPass onSubmit prop
Who submitsThe iframe calls fetch() internallyYour callback handles submission
Reporter infoAttached server-side by your route handlerYou attach it in your submit logic
Error handlingIframe shows errors from route handler / Triage APIYou 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

FieldTypeDescription
data.subjectIngestSubjectSubject from init context
data.targetReportTargetTarget from init context
data.reasonstring[]Selected reason values
data.descriptionstring | undefinedOptional description text
resolve(result?)functionCall on success. Result shown in iframe success view.
reject(error?)functionCall on failure. Error message shown in iframe error view.

Handler configuration

Options

OptionTypeRequiredDefaultDescription
apiKeystringYesSecret API key (wh_...). Never use publishable keys here.
projectIdstringYesYour project UUID.
getReporter(req) => SecureReporterContext | nullNoAnonymous (IP-based)Resolve the authenticated user. Return null for anonymous fallback.
apiUrlstringNoProduction URLCustom 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>;
}
ProvidergetReporter implementation
NextAuthtsx\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
Clerktsx\ngetReporter: (request) => {\n const { userId } = getAuth(request);\n if (!userId) return null;\n return { type: "user", externalId: userId };\n},\n
Custom cookietsx\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
});

getReportMetadata — Custom metadata

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"
}

ReportReasonInput

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

API keys

Key prefixScopeUsed by
whpk_Client-side: open hosted forms, list reasonsWhistleProvider (browser)
wh_Server-side: create reports, moderation endpointscreateWhistleHandler (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.