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
| Variable | Where to get it |
|---|---|
VALYD_API_KEY | Developer Portal → your Verify project → API key (shown once) |
VALYD_WEBHOOK_SECRET | Developer Portal → your Verify project → Webhooks |
VALYD_WORKFLOW_ID | Developer Portal → Workflows → copy workflow_id |
APP_URL | Your public server URL (e.g. https://api.example.com) |
VALYD_API_KEY=your_api_key
VALYD_WEBHOOK_SECRET=your_webhook_secret
VALYD_WORKFLOW_ID=wf_…
APP_URL=https://api.example.comnpm i @valyd/sdkCreate 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.
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 lookupscurl -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.
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).
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.
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).
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 https://idp.valyd.work/api/v2/session/ses_…/decision \
-H "X-API-Key: $VALYD_API_KEY"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 API. The query param is a UX hint only.
401 on session create
- Cause:
X-API-Keyis 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.