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.
| Variable | Where to get it |
|---|---|
VALYD_API_KEY | Developer Portal → your app’s Verification settings (shown as a Verify “project”) → app key (shown once) |
VALYD_WEBHOOK_SECRET | Developer Portal → your app’s Verification settings → Webhooks |
VALYD_WORKFLOW_ID | Developer Portal → Workflows → copy the workflow id |
APP_URL | Your public server URL (e.g. https://api.example.com) |
VALYD_API_KEY=your_app_key
VALYD_WEBHOOK_SECRET=your_webhook_secret
VALYD_WORKFLOW_ID=wf_…
APP_URL=https://api.example.comnpm i @valyd/sdkCreate 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 lookupsWhich 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.
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).
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.
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.status | What it means | What to do |
|---|---|---|
APPROVED | All checks passed. | Grant access. Store the decision against the user. |
DECLINED | One 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_REVIEW | Awaiting manual review. | Show a ‘We’ll be in touch’ message. A terminal webhook will arrive when review completes. |
ABANDONED / EXPIRED | User 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
Bufferfromexpress.raw()directly toconstructEvent(). ConfirmVALYD_WEBHOOK_SECRETmatches the secret in the Developer Portal.
Trusting ?status= as final
- Cause: Reading
req.query.statuson 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.