Skip to Content

Ship Hosted KYC

Go from zero to a working KYC flow: create a session, redirect your user to Valyd’s hosted page, receive a signed webhook, and act on the authoritative decision. Covers both “License Verification” and “KYC + License” workflows — only the workflow_id changes.

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

Prerequisites

VariableWhere to get it
VALYD_API_KEYDeveloper Portal → your Verify project → API key (shown once)
VALYD_WEBHOOK_SECRETDeveloper Portal → your Verify project → Webhooks
VALYD_WORKFLOW_IDDeveloper Portal → Workflows → copy workflow_id
APP_URLYour public server URL (e.g. https://api.example.com)
.env
VALYD_API_KEY=your_api_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

Call POST /api/v2/session from your server. The response includes a hosted url — that’s where you’ll send the user. Pass vendor_data to correlate the result back to your user later.

SDK (Node.js)
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: const session = await verify.sessions.create({ workflowId: process.env.VALYD_WORKFLOW_ID!, redirectUrl: `${process.env.APP_URL}/verify/callback`, callback: `${process.env.APP_URL}/webhooks/valyd`, vendorData: req.user.id, // echoed back on the webhook ttlSeconds: 900, }); // session.url → send the user here (step 2) // session.session_id → store this for later lookups
cURL
curl -X POST https://idp.valyd.work/api/v2/session \ -H "X-API-Key: $VALYD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "workflow_id": "wf_…", "redirect_url": "https://app.example.com/verify/callback", "callback": "https://api.example.com/webhooks/valyd", "vendor_data": "user_123", "ttl_seconds": 900 }'

Which workflow_id? 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 workflow_id differs.

Redirect the user

Send the user’s browser to session.url. Valyd’s hosted 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!, redirectUrl: `${process.env.APP_URL}/verify/callback`, callback: `${process.env.APP_URL}/webhooks/valyd`, vendorData: req.body.userId, }); res.redirect(session.url); });

Handle the redirect back

When the user finishes (or abandons), Valyd redirects to your redirect_url 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 API (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 API.

Receive and verify the webhook

When the session reaches a terminal state, Valyd POSTs to your callback URL. You must verify the HMAC-SHA256 signature against the raw request body — do not re-serialise the JSON. Use X-Valyd-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.session_id → use to fetch the full decision (step 5) // event.vendor_data → 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 call the decision endpoint for the full check breakdown.

Read the authoritative decision

Call GET /api/v2/session/{id}/decision 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).

SDK (Node.js)
const d = await verify.sessions.decision(event.session_id); // 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" }
cURL
curl https://idp.valyd.work/api/v2/session/ses_…/decision \ -H "X-API-Key: $VALYD_API_KEY"

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 API. The query param is a UX hint only.

401 on session create

  • Cause: X-API-Key is missing, wrong, or being sent from the browser.
  • Fix: Keep the API key server-side only. Confirm the key is the App API key, not a different credential type.

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.