---
title: KYC Agent
description: Gate high-value transactions on identity verification. Agent fires codespar_kyc, polls or streams verificationStatus, then routes to approved or rejected branch.
---

<ActionBar githubPath="content/docs/cookbooks/kyc-agent.mdx" markdownPath="/api/docs-md/cookbooks/kyc-agent" />

<MetaStrip items={[
  { label: "TIME", value: "~15 min" },
  { label: "PROVIDER", value: (<><ServerChip name="OpenAI" accent /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>gpt-4o</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="persona" /><ServerChip name="asaas" /><ServerChip name="z-api" /></>) },
]} />

A KYC gate is the canonical pattern for any high-value transaction in LATAM. The agent receives a transfer request, fires a `codespar_kyc` inquiry, waits for the verdict (poll or stream), and routes to the approved branch (release the payment) or the rejected branch (notify the user, ask for a different document).

<PixFlow
  steps={[
    { label: "Submit inquiry", tool: "codespar_kyc", description: "Routed to Persona — returns tool_call_id, status: pending", iconKey: "doc" },
    { label: "Wait for verdict", tool: "session.verificationStatusStream", description: "SSE stream until terminal: approved / rejected / review / expired", iconKey: "search" },
    { label: "Approved → release", tool: "codespar_pay", description: "Routed to Asaas — Pix payout to the verified recipient", iconKey: "send" },
    { label: "Rejected → notify", tool: "codespar_notify", description: "Routed to Z-API — WhatsApp asks for a different document", iconKey: "chat" },
  ]}
/>

## Prerequisites

```bash
npm install @codespar/sdk @codespar/openai openai
```

```bash title=".env"
CODESPAR_API_KEY=csk_live_...
OPENAI_API_KEY=sk-...
```

<Callout>
You need active accounts on **Persona** (default KYC rail), **Asaas** (Pix payouts), and **Z-API** (WhatsApp notifications). Persona requires `inquiry_template_id` stamped in `connection_metadata` when you connect — see the gotcha below.
</Callout>

## The Persona `inquiry_template_id` gotcha

Persona's API is multi-tenant by template — every inquiry must reference an `inquiry_template_id` (e.g. `itmpl_AbCdEf...`). Rather than passing this on every `session.execute()`, CodeSpar stores it in `connected_accounts.connection_metadata` (jsonb) when the operator connects Persona.

When you click Connect on `/dashboard/auth-configs` for Persona, the connect modal asks for:

1. Persona API key (the credential)
2. Persona `inquiry_template_id` (operator-stamped metadata)

Without the template id, every `codespar_kyc` call fails with `inquiry_template_required`. The error surfaces clearly in the dashboard's tool-call log; if you hit it, the fix is to re-open the Persona connection and add the template id.

## Pattern: KYC gate before payout

The agent receives a request to release a R$ 50,000 payout. It fires KYC, polls until terminal, and only releases the payment if `approved`.

```typescript title="kyc-gate-agent.ts"
import OpenAI from "openai";
import { CodeSpar } from "@codespar/sdk";
import { getTools, handleToolCall } from "@codespar/openai";

const openai = new OpenAI();
const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });

async function payoutWithKYCGate(
  userId: string,
  payoutAmount: number,
  recipient: { name: string; document: string; phone: string; pix_key: string }
) {
  const session = await cs.create(userId, {
    servers: ["persona", "asaas", "z-api"],
  });

  try {
    // 1. Fire KYC inquiry
    const inquiry = await session.execute("codespar_kyc", {
      operation: "create_inquiry",
      subject: {
        name: recipient.name,
        document: recipient.document,
        document_type: "cpf",
      },
      metadata: { payout_amount: payoutAmount },
    });

    console.log("KYC fired:", inquiry.tool_call_id);

    // 2. Poll until terminal
    let v = await session.verificationStatus(inquiry.tool_call_id);
    while (v.status === "pending") {
      await new Promise((r) => setTimeout(r, 3000));
      v = await session.verificationStatus(inquiry.tool_call_id);
    }

    // 3. Route on the verdict
    if (v.status === "approved") {
      const payout = await session.execute("codespar_pay", {
        amount: payoutAmount,
        currency: "BRL",
        method: "pix",
        recipient: {
          document: recipient.document,
          pix_key: recipient.pix_key,
        },
        idempotency_key: crypto.randomUUID(),
      });

      await session.execute("codespar_notify", {
        recipient: recipient.phone,
        channel: "whatsapp",
        message: `Olá ${recipient.name}, sua identidade foi verificada e o pagamento foi liberado.`,
      });

      return { ok: true, payout_id: payout.tool_call_id };
    }

    if (v.status === "rejected" || v.status === "expired") {
      await session.execute("codespar_notify", {
        recipient: recipient.phone,
        channel: "whatsapp",
        message: `Olá ${recipient.name}, não conseguimos verificar sua identidade. Por favor, tente novamente com um documento diferente.`,
      });
      return { ok: false, reason: v.status };
    }

    // status === "review" — manual review pending; surface to operator dashboard
    return { ok: false, reason: "manual_review" };
  } finally {
    await session.close();
  }
}
```

## Streaming variant

Polling every 3 seconds is fine for a CLI script; for a live UX (a chat agent telling the user "verifying..." and then "you're approved"), use `verificationStatusStream`:

```typescript title="kyc-stream.ts"
const inquiry = await session.execute("codespar_kyc", {
  operation: "create_inquiry",
  subject: { name: "Maria Silva", document: "12345678900", document_type: "cpf" },
});

await session.verificationStatusStream(inquiry.tool_call_id, {
  onUpdate: (snapshot) => {
    // Server pushes a `snapshot` event with the current state, then `update` events
    // on each transition. Stream closes after a 5s grace following the terminal state.
    console.log("kyc:", snapshot.status);
    if (snapshot.status === "approved") {
      releasePayment();
    }
    if (snapshot.status === "rejected") {
      notifyUser("KYC failed — please try a different document.");
    }
  },
});
```

The server emits a heartbeat every 15 seconds to prevent proxies from dropping the connection. After a terminal status, the server holds the stream open for 5 seconds so late subscribers receive the final snapshot, then closes.

## Verdict priorities

When multiple events have landed for the same `tool_call_id` (e.g. `review` then `approved`), `verificationStatus` returns the highest-priority terminal state, in this order:

1. `approved`
2. `rejected`
3. `review`
4. `expired`
5. `pending`

This is why the polling loop above checks `=== "pending"` (not `!== "approved"`) — `review` is a stable terminal state that stays until human review resolves it.

## Variations

### Swap to Sift / Konduto / Truora

Pass `provider: "sift"` (or `"konduto"`, `"truora"`) on the `codespar_kyc` call — the rest of the flow is unchanged. Sift returns a fraud score rather than a binary verdict; map score thresholds to your own approve/reject logic.

### Cache verdicts

A passed KYC inquiry is good for ~30-90 days at most providers. Cache the `tool_call_id` in your database keyed by `recipient.document` and skip the inquiry if a recent approved verdict exists.

### Combine with codespar_charge

Same pattern, inverted: gate `codespar_charge` (inbound) on the buyer's KYC for high-value carts. Useful for marketplaces with seller-driven listings where the buyer's identity matters.

## Next steps

<NextStepsGrid items={[
  { label: "CONCEPT", title: "codespar_kyc", description: "Args / result / operator setup details.", href: "/docs/concepts/meta-tools/kyc" },
  { label: "CONCEPT", title: "Async settlement → KYC sibling", description: "Why verification follows the same correlation chain as payments.", href: "/docs/concepts/async-settlement#verification-kyc-sibling" },
  { label: "CONCEPT", title: "SSE streaming", description: "Event shape, heartbeats, cancellation for verificationStatusStream.", href: "/docs/concepts/sse-streaming" },
  { label: "REFERENCE", title: "verificationStatus", description: "Full SDK reference for the polling + streaming wrappers.", href: "/docs/api/sdk#verificationstatustoolcallid-promiseverificationstatusresult" },
]} />
