Skip to main content

KYC Agent

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

1 min read · updated
View MarkdownEdit on GitHub
TIME
~15 min
PROVIDER
OpenAIgpt-4o
SERVERS
personaasaasz-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).

Prerequisites

npm install @codespar/sdk @codespar/openai openai
.env
CODESPAR_API_KEY=csk_live_...
OPENAI_API_KEY=sk-...

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.

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.

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:

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

Edit on GitHub

Last updated on