Integration Guide

This is the pattern we recommend for building a real identity verification flow on top of the Dynamic KBA endpoint. It is drawn from a live production integration and covers the parts that the raw endpoint reference does not: how to keep the answer key on your server, how to store and present questions, how to time each round, and how to score attempts and offer retries.

Every request in this guide goes to POST {TRUEST_HUMAN_API_ENDPOINT}/dkba with your API key in the Authorization header. For the complete request and response schemas, see the AI Prompt page.

Architecture at a glance

The single most important rule: never call this API from the browser. Your API key and the answer key (which option isCorrect) must both stay server-side. The browser only ever sees one question at a time, with the answers shuffled and the correct flag stripped.

Browser  ──►  Your API route  ──►  Truest Human  POST /api/dkba
   ▲               │
   │               ▼
   │        Your database  (questions + answer key stay here)
   │               │
   └───────────────┘
     one question at a time, no answer key ever leaves your server

The flow has five moving parts, each covered below:

  1. Schemas — validate everything you send and receive.
  2. Request helper — one server-side function that calls the API.
  3. Store — persist the questions (and the answer key) in your database.
  4. Present safely — send questions to the client without the answers.
  5. Time & score — run each round against a server-owned deadline and score attempts.

1. Model requests and responses with schemas

Define one schema module and infer your types from it. Validate the outbound payload (a bad request is a wasted, billed call) and the inbound response (a shape change should fail loudly here, not deep in your UI).

// lib/dkba-schemas.ts
import { z } from "zod";

// What you SEND. Validate it before every call so a malformed payload
// fails in your code instead of as a wasted (billed) API round-trip.
export const dkbaRequestSchema = z.object({
  firstName: z.string(),
  middleName: z.string().optional(),
  lastName: z.string(),
  generation: z.string().optional(),
  street: z.string(),
  city: z.string(),
  state: z.string(),
  zipCode: z.string(),
  dateOfBirth: z.string(),                 // "YYYY-MM-DD"
  ssn: z.string(),                         // last 4 digits only
  phoneNumber: z.string().nullable().optional(),
  emailAddress: z.string().optional(),
  ipAddress: z.string().optional(),
});
export type DkbaRequest = z.infer<typeof dkbaRequestSchema>;

// What you RECEIVE. Note the isCorrect flag on every answer — that is the
// answer key. It must stay on your server (see "Present questions safely").
const evsAnswer = z.object({ text: z.string(), isCorrect: z.boolean() });
const evsQuestion = z.object({
  text: z.string(),
  questionType: z.number(),
  answers: z.array(evsAnswer),
});
const evsResult = z.object({ code: z.string(), description: z.string().optional() });

const dkbaSuccess = z.object({
  ok: z.literal(true),
  data: z.object({
    kbaQuestions: z.object({ questions: z.array(evsQuestion) }),
    workflowOutcome: evsResult,            // code: "P" | "R" | "F"
    dkba: z.object({
      consumerIdDetail: z.object({ /* name + address on file */ }).optional(),
      dateOfBirthResult: evsResult,
      phoneVerificationResult: evsResult,
      socialSecurityNumberResult: evsResult,
    }),
  }),
});
const dkbaFailure = z.object({ ok: z.literal(false), errorMessage: z.string() });

export const dkbaResponseSchema = z.union([dkbaSuccess, dkbaFailure]);
export type DkbaResponse = z.infer<typeof dkbaResponseSchema>;

2. One server-side request helper

Wrap the call in a single function. It reads the key from the environment, validates in both directions, and throws on ok: false so callers only deal with the success shape.

// lib/request-dkba.ts
import {
  DkbaRequest,
  dkbaRequestSchema,
  dkbaResponseSchema,
} from "./dkba-schemas";

export async function requestDkba(request: DkbaRequest) {
  const payload = dkbaRequestSchema.parse(request); // validate what you SEND

  const endpoint = process.env.TRUEST_HUMAN_API_ENDPOINT;
  const apiKey = process.env.TRUEST_HUMAN_API_KEY;
  if (!endpoint) throw new Error("TRUEST_HUMAN_API_ENDPOINT is not defined");
  if (!apiKey) throw new Error("TRUEST_HUMAN_API_KEY is not defined");

  const response = await fetch(`${endpoint}/dkba`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: apiKey, // no "Bearer" prefix
    },
    body: JSON.stringify(payload),
  });

  const json: unknown = await response.json();
  const parsed = dkbaResponseSchema.parse(json); // validate what you RECEIVE

  if (!parsed.ok) throw new Error(parsed.errorMessage);
  return parsed;
}

Add these to your .env (use a test key while building):

TRUEST_HUMAN_API_KEY=th_test_your_key_here
TRUEST_HUMAN_API_ENDPOINT=https://truesthuman.com/api

3. Look up identity, then store the questions

Your verification route authenticates your own user, calls the helper, and interprets the result. There are four outcomes to handle: an identity failure, a thin file, an already-in-progress user, and the happy path where you store the questions.

// app/api/verify/route.ts — look up identity, then store the questions
import { NextResponse } from "next/server";
import { requestDkba } from "@/lib/request-dkba";
// ...your own db, auth, id + shuffle helpers

export async function POST(request: Request) {
  const currentUser = await getCurrentUser(); // authenticate YOUR user first
  const body = await request.json();

  // Already have questions for this user? Serve the next stored one instead
  // of paying for a second lookup that would return the same answer.
  const existing = await db.query.question.findMany({
    where: eq(question.userId, currentUser.id),
  });
  if (existing.length > 0) {
    return NextResponse.json({
      ok: true,
      question: await getNextCleanQuestion(currentUser),
    });
  }

  const result = await requestDkba({
    firstName: currentUser.firstName,
    lastName: currentUser.lastName,
    middleName: currentUser.middleName ?? undefined,
    street: [body.addressLine1, body.addressLine2].filter(Boolean).join(" "),
    city: body.city,
    state: body.state,
    zipCode: body.zipCode,
    dateOfBirth: currentUser.dateOfBirth, // "YYYY-MM-DD"
    ssn: currentUser.ssn,                 // last 4 digits
    phoneNumber: currentUser.phoneNumber,
    ipAddress: getIpAddress(request),
  });

  // 1) Did we positively identify the person? Require BOTH a passing
  //    workflow code AND an identity record on file.
  const codePassed = ["P", "R"].includes(result.data.workflowOutcome.code);
  const idPassed = result.data.dkba.consumerIdDetail != null;
  if (!codePassed || !idPassed) {
    // First identity failure: allow one more attempt. Second: block.
    if (currentUser.dkbaCodeFailed) {
      await db.update(user).set({ blocked: true }).where(eq(user.id, currentUser.id));
      return NextResponse.json({ ok: true, blocked: true });
    }
    await db.update(user).set({ dkbaCodeFailed: true }).where(eq(user.id, currentUser.id));
    return NextResponse.json({ ok: true, retry: true });
  }

  // 2) Thin file: the identity is real but there are no questions to ask.
  //    Persist it so you never re-charge for the same dead-end lookup.
  const questions = result.data.kbaQuestions.questions;
  if (questions.length === 0) {
    await db.update(user).set({ dkbaNoQuestions: true }).where(eq(user.id, currentUser.id));
    return NextResponse.json({ ok: true, noQuestions: true });
  }

  // 3) Store every question and its options. Keep the correct flag in your
  //    DB — it is the answer key and must never reach the browser.
  for (const [index, q] of shuffle(questions).entries()) {
    const questionId = createId();
    await db.insert(question).values({
      id: questionId,
      prompt: q.text,
      priority1: q.questionType,
      priority2: index,
      userId: currentUser.id,
    });
    await db.insert(questionOption).values(
      q.answers.map((a) => ({
        id: createId(),
        questionId,
        text: a.text,
        correct: a.isCorrect, // stored server-side ONLY
      })),
    );
  }

  return NextResponse.json({
    ok: true,
    question: await getNextCleanQuestion(currentUser),
  });
}

Three things worth calling out:

  • Identity check: require both a passing workflowOutcome.code (P or R) and a non-null consumerIdDetail.
  • Thin file: a passing identity with 0 questions means the person is real but cannot be verified with knowledge questions. Persist that state so you never re-charge for the same dead-end lookup.
  • Don't pay twice: if the user already has stored questions, serve the next one instead of calling the API again. Each live lookup bills.

4. Present questions safely

This is the security-critical step. When you send a question to the client, build an explicit object with only the fields the browser needs. Do not spread the database row — that leaks the correct flag on every option, which is the entire answer key.

// lib/clean-question.ts — turn a stored question into something safe to send
import shuffle from "./shuffle";

export function cleanQuestion(props: { number: number; question: StoredQuestion }) {
  const correct = props.question.options.find((o) => o.correct);
  if (!correct) throw new Error("Question has no correct option");

  const incorrect = props.question.options.filter((o) => !o.correct);
  if (incorrect.length < 2) throw new Error("Need at least two distractors");

  // One correct option + up to four distractors, then shuffle so the
  // position of the answer does not leak it.
  const display = shuffle([correct, ...incorrect.slice(0, 4)]);

  // Build an EXPLICIT object. Never spread the DB row — that would ship
  // every option's correct flag (the answer key) to the browser.
  return {
    id: props.question.id,
    number: props.number,
    prompt: props.question.prompt,
    options: display.map((o) => o.text), // text only, never the correct flag
  };
}

5. Time each round

Each round is capped at two minutes. The deadline lives on the server (a roundDeadline timestamp on the user). The client only renders a countdown derived from it and pings the server when it hits zero — the server always makes the real decision.

Start the clock

When the user enters a round, set the deadline. Make it idempotent so a refresh or a second tab returns the existing deadline instead of granting free time.

// app/api/timer/route.ts — start the round clock
export async function POST() {
  const currentUser = await getCurrentUser();
  if (currentUser.dkbaVerified) throw new ApiError(400, "Already verified");

  // Idempotent: if a deadline already exists (refresh, second tab), return
  // it instead of resetting the clock and giving the user free time.
  if (currentUser.roundDeadline) {
    return NextResponse.json({
      ok: true,
      roundDeadline: new Date(currentUser.roundDeadline).getTime(),
      question: await getNextCleanQuestion(currentUser),
    });
  }

  // Two minutes to complete the round.
  const roundDeadline = new Date();
  roundDeadline.setMinutes(roundDeadline.getMinutes() + 2);

  const question = await getNextCleanQuestion(currentUser);
  await db
    .update(user)
    .set({ dkbaStarted: true, roundDeadline })
    .where(eq(user.id, currentUser.id));

  return NextResponse.json({
    ok: true,
    roundDeadline: roundDeadline.getTime(),
    question,
  });
}

Display-only countdown

The client timer is cosmetic. It counts down from the server deadline and calls your time-out route once when it expires.

// components/round-timer.tsx — DISPLAY ONLY; the server owns the real deadline
"use client";
import { useEffect, useRef, useState } from "react";

export function RoundTimer(props: { seconds: number; onExpire: () => void }) {
  const [seconds, setSeconds] = useState(props.seconds);
  const firedRef = useRef(false); // fire onExpire at most once

  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds((prev) => {
        if (prev < 1) {
          clearInterval(interval);
          if (!firedRef.current) {
            firedRef.current = true;
            props.onExpire(); // POST to your time-out route — server decides
          }
          return 0;
        }
        return prev - 1;
      });
    }, 1000);
    return () => clearInterval(interval);
  }, [props]);

  const m = Math.floor(seconds / 60);
  const s = Math.floor(seconds % 60).toString().padStart(2, "0");
  // seconds is derived from the server deadline minus now; render only.
  return <div suppressHydrationWarning>{m}:{s}</div>;
}

The server is authoritative

When the timer fires, the server re-checks the deadline before acting. Without this, a client could POST early to skip questions. The same check belongs at the top of your answer route, before any answer is recorded.

// app/api/time-out/route.ts — the SERVER decides whether time is up
export async function POST() {
  const currentUser = await getCurrentUser();
  if (currentUser.dkbaVerified) {
    return NextResponse.json({ ok: true, round2: currentUser.round2 });
  }
  if (!currentUser.roundDeadline) throw new ApiError(400, "No round deadline");

  // Never trust the client clock. Re-check the deadline here, or a user
  // could POST early to skip questions and jump to a fresh round.
  if (new Date(currentUser.roundDeadline) > new Date()) {
    throw new ApiError(400, "Round has not expired yet");
  }

  const questions = await db.query.question.findMany({
    where: eq(question.userId, currentUser.id),
    with: { questionOptions: true },
  });
  return handleRoundTimeout({
    questions,
    round2: currentUser.round2,
    userId: currentUser.id,
  });
}

Catch abandoned sessions with a sweep

The interactive timeout only fires while the page is open. A user who closes the tab leaves an expired deadline that never resolves. A small scheduled job applies the same timeout logic to every expired, unverified, unblocked round.

// app/api/cron/expire-kba-rounds/route.ts — catch abandoned tabs
export const maxDuration = 60;

export async function GET(request: Request) {
  // The interactive timeout only fires while the verify page stays open.
  // A user who closes the tab leaves an expired deadline that never
  // resolves — so sweep them on a schedule (e.g. a Vercel Cron every minute).
  const secret = process.env.CRON_SECRET;
  if (request.headers.get("authorization") !== `Bearer ${secret}`) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const expired = await db.query.user.findMany({
    where: and(
      isNotNull(user.roundDeadline),
      lt(user.roundDeadline, new Date()),
      eq(user.dkbaVerified, false),
      eq(user.blocked, false),
    ),
    columns: { id: true, round2: true },
  });

  for (const u of expired) {
    const questions = await db.query.question.findMany({
      where: eq(question.userId, u.id),
      with: { questionOptions: true },
    });
    // Same handler the interactive route uses. Idempotent: it clears
    // roundDeadline, so a processed user drops out of the next sweep.
    await handleRoundTimeout({ questions, round2: u.round2, userId: u.id });
  }

  return NextResponse.json({ processed: expired.length });
}

6. Score answers and allow attempts

Questions are scored one at a time against the answer key in your database. A round is five questions; passing requires four of five correct. The attempt model is forgiving: a first-round failure drops the user into a second round with different questions, and a second-round failure resets them to round one to try again rather than hard-blocking.

// app/api/answer/route.ts — score one answer, then advance or finish the round
export async function POST(request: Request) {
  const currentUser = await getCurrentUser();
  if (currentUser.blocked) throw new ApiError(403, "User is blocked");
  if (currentUser.dkbaVerified) {
    return NextResponse.json({ ok: true, dkbaVerified: true, round2: currentUser.round2 });
  }
  if (!currentUser.roundDeadline) throw new ApiError(500, "No round deadline");

  // Check the deadline BEFORE recording the answer, so a late submission
  // cannot sneak in past the timer.
  if (new Date(currentUser.roundDeadline) < new Date()) {
    const questions = await loadQuestions(currentUser.id);
    return handleRoundTimeout({ questions, round2: currentUser.round2, userId: currentUser.id });
  }

  const { questionId, answer } = answerRequestSchema.parse(await request.json());
  const current = await getNextQuestion({ userId: currentUser.id, round2: currentUser.round2 });

  // Reject a client that is out of sync (stale tab, replay, race with timer).
  if (questionId !== current.id) {
    throw new ApiError(409, "Question mismatch: client and server are out of sync");
  }

  // Score against the DB — the browser never sent us the answer key.
  const correctOption = current.questionOptions.find((o) => o.correct);
  const correct = answer === correctOption?.text;
  await db
    .update(question)
    .set({ answered: true, everAnswered: true, correct })
    .where(eq(question.id, current.id));

  // Finished the 5 questions in this round?
  const answered = await answeredQuestionsThisRound(currentUser);
  if (answered.length === 5) {
    const passed = answered.filter((q) => q.correct).length >= 4; // 4 of 5

    if (!currentUser.round2) {
      // End of Round 1.
      if (passed) return markVerified(currentUser);
      return failRound({ userId: currentUser.id, questions: answered }); // -> Round 2
    }

    // End of Round 2.
    if (passed) return markVerified(currentUser);
    return resetToRoundOne(currentUser); // don't hard-block; let them retry
  }

  // Otherwise serve the next question in this round.
  return buildQuestionResponse({
    round2: currentUser.round2,
    number: answered.length + 1,
    question: await getNextQuestion(currentUser),
  });
}

The failure paths (failRound, resetToRoundOne, and the shared handleRoundTimeout) each reshuffle or recycle questions and flip the round flags. The full attempt flow:

provide details
  └─ identity lookup  (POST /api/dkba)
       ├─ Fail                → retry once, then BLOCK
       ├─ Thin file (0 Qs)    → contact support (no retry, no re-charge)
       └─ Pass                → store questions, start Round 1 (2:00)

           Round 1 · 5 questions · need 4 of 5
             ├─ Pass          → VERIFIED
             └─ Fail / timeout → Round 2 (2:00, fresh + recycled questions)

                 Round 2 · 5 questions · need 4 of 5
                   ├─ Pass          → VERIFIED
                   └─ Fail / timeout → reset to Round 1 (try again later)

The user state machine

A handful of boolean/timestamp fields on your user record drive the whole flow. Keeping them explicit makes the flow easy to reason about and to resume after a refresh:

FieldMeaning
dkbaStartedThe user has begun a round.
roundDeadlineWhen the current round ends. null between rounds.
round2The user is in their second round.
dkbaVerifiedPassed a round. Terminal success.
dkbaCodeFailedA prior identity lookup failed once; a second failure blocks.
dkbaNoQuestionsThin file — identity real, no questions. Terminal, no re-charge.
blockedLocked out after repeated identity failure.

Best-practices checklist

  • Proxy every call through your server — the API key never touches the browser.
  • Validate both the request and the response against a schema.
  • Store the answer key server-side; strip the correct flag before sending a question.
  • The server owns the clock. Re-check the deadline on every scored action, and never trust a client-supplied time.
  • Back the interactive timer with a scheduled sweep for abandoned sessions.
  • Treat identity failures and thin files as terminal states you persist, so you never pay for a repeat lookup that cannot change the outcome.
  • Guard against out-of-sync clients: verify the answered question id matches the server's current question.
  • Build against a test key; only the live key bills.