Skip to Content

Ship KYC with a workflow

Go from zero to a working KYC flow: create a verification session with the SDK, send your connected user to Valyd’s verification page, receive a signed webhook, and act on the authoritative decision. This runs on Reusable Verification — the user connects with Valyd, you open a verification session on their valyd_access_token, and passed proofs save to their Valyd ID (reusable). Covers both “License Verification” and “KYC + License” workflows — only the workflowId changes.

~30 min · Express / Node.js · @valyd/sdk

Prerequisites

Everything comes from the Developer Portal  — there is no separate Verify console and no raw API to call.

VariableWhere to get it
VALYD_API_KEYDeveloper Portal → your app’s Verification settings (shown as a Verify “project”) → app key (shown once)
VALYD_WEBHOOK_SECRETDeveloper Portal → your app’s Verification settings → Webhooks
VALYD_WORKFLOW_IDDeveloper Portal → Workflows → copy the workflow id
APP_URLYour public server URL (e.g. https://api.example.com)
.env
VALYD_API_KEY=your_app_key VALYD_WEBHOOK_SECRET=your_webhook_secret VALYD_WORKFLOW_ID=wf_… APP_URL=https://api.example.com
Install the SDK
npm i @valyd/sdk

Create a session

Once the user has connected with Valyd, call verify.sessions.create from your server with their valydAccessToken. The SDK returns the verification page url — that’s where you’ll send the user. Pass vendorData to correlate the result back to your user later.

import { VerifyClient } from "@valyd/sdk"; const verify = new VerifyClient({ apiKey: process.env.VALYD_API_KEY!, webhookSecret: process.env.VALYD_WEBHOOK_SECRET!, }); // In your route handler (the user has already connected with Valyd): const session = await verify.sessions.create({ workflowId: process.env.VALYD_WORKFLOW_ID!, valydAccessToken: user.valydAccessToken, // ← identifies the person → valyd_id redirectUrl: `${process.env.APP_URL}/verify/callback`, callback: `${process.env.APP_URL}/webhooks/valyd`, vendorData: user.id, // echoed back on the webhook ttlSeconds: 900, }); // session.url → send the user here (step 2) // session.sessionId → store this for later lookups

Which workflow? Use the “License Verification” workflow to check a professional license only. Use “KYC + License” to also verify the user’s identity (ID scan + selfie + face match) before the license lookup. Both use the same integration code — only the workflowId differs. Compose either one in the Developer Portal .

Redirect the user

Send the user’s browser to session.url. Valyd’s verification page handles the entire capture and verification UI — no camera or document handling on your side.

Express route
app.post("/start-verification", express.json(), async (req, res) => { const session = await verify.sessions.create({ workflowId: process.env.VALYD_WORKFLOW_ID!, valydAccessToken: req.user.valydAccessToken, redirectUrl: `${process.env.APP_URL}/verify/callback`, callback: `${process.env.APP_URL}/webhooks/valyd`, vendorData: req.user.id, }); res.redirect(session.url); });

Handle the redirect back

When the user finishes (or abandons), Valyd redirects to your redirectUrl with ?session_id=…&status=…. The status query param is a hint — never treat it as the final result. Your authoritative source is the webhook (step 4) and the decision call (step 5).

Express route
app.get("/verify/callback", (req, res) => { const { session_id } = req.query; // ?status= is a hint only — don't gate access on it. // Show a "processing" page while you wait for the webhook. res.redirect(`/verify/pending?s=${session_id}`); });

Never trust ?status=APPROVED from the redirect URL. A user can manipulate query params. Always confirm via the webhook or the decision call.

Receive and verify the webhook

When the session reaches a terminal state, Valyd POSTs to your callback URL. Let the SDK verify the signature against the raw request body — do not re-serialise the JSON. Use the event id to deduplicate retries.

Express webhook handler
import { ValydVerifyError } from "@valyd/sdk"; // IMPORTANT: raw body required for signature verification app.post( "/webhooks/valyd", express.raw({ type: "application/json" }), async (req, res) => { try { const event = verify.webhooks.constructEvent(req.body, req.headers); // event.type → "verification.approved" | "verification.declined" | … // event.sessionId → use to fetch the full decision (step 5) // event.vendorData → your internal user ref // Deduplicate — idempotency on re-delivery if (await alreadyProcessed(event.event_id)) { return res.json({ ok: true }); } await handleEvent(event); // your business logic res.json({ ok: true }); } catch (err) { if (err instanceof ValydVerifyError && err.code === "invalid_signature") { return res.status(400).send("bad signature"); } throw err; } } );

Webhook event types: verification.approved, verification.declined, verification.in_review, verification.abandoned, verification.expired. The webhook is a notification — always read the decision for the full check breakdown.

Read the authoritative decision

Call verify.sessions.decision(id) to get the final outcome plus per-check details. Do this inside your webhook handler (or from a polling mechanism if the webhook hasn’t arrived yet).

const d = await verify.sessions.decision(event.sessionId); // d.status → "APPROVED" | "DECLINED" | "IN_REVIEW" // d.checks → [{ type, status, score, data, error }] const credential = d.checks.find(c => c.type === "credential"); if (credential?.status === "failed") { console.error("License check failed:", credential.error?.message); // e.g. "License belongs to a different name" }

Handle the result

d.statusWhat it meansWhat to do
APPROVEDAll checks passed.Grant access. Store the decision against the user.
DECLINEDOne or more checks failed.Show a clear message. Inspect d.checks for which check failed and why. Don’t reveal raw error messages to the user.
IN_REVIEWAwaiting manual review.Show a ‘We’ll be in touch’ message. A terminal webhook will arrive when review completes.
ABANDONED / EXPIREDUser left or session timed out.Offer to restart. Create a new session — sessions cannot be resumed.

For KYC + License, APPROVED means all four checks passed: the ID was authentic, the selfie was live, the selfie matched the ID portrait, and the license belongs to the person on the ID.

Common errors

Invalid webhook signature

  • Cause: Verifying against a re-serialised JSON body, or using the wrong secret.
  • Fix: Pass the raw Buffer from express.raw() directly to constructEvent(). Confirm VALYD_WEBHOOK_SECRET matches the secret in the Developer Portal.

Trusting ?status= as final

  • Cause: Reading req.query.status on the redirect callback and gating access on it.
  • Fix: Always confirm the outcome via the webhook or the decision call. The query param is a UX hint only.

Unauthorized on session create

  • Cause: The SDK app key is missing, wrong, or being used from the browser.
  • Fix: Keep the app key server-side only. Confirm it is the app’s Verify key, not a different credential type, and that the session carries a valid valydAccessToken.

Webhook not received

  • Cause: The callback URL isn’t publicly reachable, or returns a non-2xx response.
  • Fix: In development, use a tunnel (ngrok, Cloudflare Tunnel). Your handler must return 2xx within ~30 s — do heavy work async.
Last updated on