Skip to Content
Verification APIsHosted Verification

Hosted Verification

Scope: Non-account (Fresh) hosted. No Valyd login, nothing retained; the decision returns the raw captured data. For the Account variant — result stored on the user’s Valyd account, reuse (returning users verify with a selfie only), and proofs-only results — see Account (Managed by Valyd) . Same hosted page; you additionally pass the user’s valyd_access_token when creating the session.

Agent Quick-Start

  • Source URL: https://docs.valyd.work/verify#hosted 
  • Credentials / env vars needed: VALYD_API_KEY, VALYD_WEBHOOK_SECRET, VALYD_WORKFLOW_ID (workflow created in the Developer Portal: https://dev.valyd.work ), APP_URL
  • Files an integrator edits: .env (credentials), server route handlers (session create, redirect callback, webhook receiver)
  • Estimated steps: 4 (create session → redirect → handle redirect-back → read authoritative result)
  • Can complete without human input: NO — a human must create the App API key, the webhook signing secret, and a workflow in the Developer Portal to obtain a workflow_id.
  • Prerequisites:
    • An App API key (X-API-Key) — server-side only, never exposed to the browser. Get this from the Developer Portal: https://dev.valyd.work 
    • A webhook signing secret (VALYD_WEBHOOK_SECRET) — get this from the Developer Portal: https://dev.valyd.work 
    • A workflow_id — create the workflow in the Developer Portal (Workflows → “License Verification” or “KYC + License” preset) or via the API, then copy its workflow_id.
    • Node.js project with @valyd/sdk installed (for the SDK examples).

ALL server-to-server calls use the header X-API-Key: <App API key>. Keep this key SERVER-SIDE ONLY — never expose it to the browser. Every response uses the envelope { success, data, error: { code, message } }.

Base URL note: the component resolves the API base URL from VERIFY_CONFIG.API_BASE_URL = https://idp.valyd.work. The hosted session url returned by the API is also under https://idp.valyd.work/s/…. This page’s canonical doc URL is on a different host (https://docs.valyd.work), which is expected for docs vs. API.


Overview

Hosted Verification lets you create a session, redirect the user to a Valyd-hosted page that captures everything, and receive the result via a signed webhook plus the decision API. No UI to build, no PII handled by you.

The official Node SDK is @valyd/sdk (https://www.npmjs.com/package/@valyd/sdk ). It wraps sessions, workflows, webhooks, and the decision API.

Install it:

npm i @valyd/sdk

Initialize the client:

import { VerifyClient } from "@valyd/sdk"; const verify = new VerifyClient({ apiKey: process.env.VALYD_API_KEY!, webhookSecret: process.env.VALYD_WEBHOOK_SECRET!, // used by verify.webhooks.constructEvent });

Hosted flow at a glance:

  1. Create a session on your server with a workflow_id.
  2. Redirect the user’s browser to the returned url.
  3. Valyd captures everything and redirects back to your redirect_url.
  4. Receive a signed webhook, then fetch the authoritative result from the decision API.

The two hosted products

Both products use the SAME integration code — only the workflow_id differs. Create the workflow in the Developer Portal (https://dev.valyd.work ) under Workflows → “License Verification” or “KYC + License” preset, or via the API, then copy its workflow_id.

IF you only need to verify a professional license (no ID scan): → use the "License Verification" workflow_id IF you need identity + credential (ID scan + selfie + license): → use the "KYC + License" workflow_id IF unsure which workflow_id to use: → open the Developer Portal (https://dev.valyd.work) → Workflows, and copy the workflow_id of the preset you created

License Verification (badge: Credential only)

  • Services: [credential]
  • Hosted flow: State → license type → name + license number → verify.
  • Fastest path to verify a professional license. No ID scan required.

KYC + License (badge: Identity + Credential)

  • Services: [id_verification, liveness, face_match, credential]
  • Hosted flow: Scan ID + selfie (OCR + liveness + 1:1 face match), then state + license type + license number.
  • The name is taken from the verified ID automatically (the user doesn’t type it), so a license belonging to a different person is rejected.

Recipe: Integration steps

The steps are identical for both products. Swap the workflow_id to switch between License Verification and KYC + License.

Prerequisites

  • VALYD_API_KEY set server-side (App API key — get from the Developer Portal: https://dev.valyd.work ).
  • VALYD_WEBHOOK_SECRET set server-side (webhook signing secret — get from the Developer Portal: https://dev.valyd.work ).
  • VALYD_WORKFLOW_ID — the workflow_id of the workflow you want to run (get from the Developer Portal: https://dev.valyd.work , or create via the Workflows API below).
  • APP_URL — your application’s public base URL (used to build redirect_url and callback).

Steps

Step 1 — Create a session (server-side)

POST /api/v2/session from your backend. The response includes the hosted url you’ll send the user to.

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, "metadata": { "plan": "pro" } }'

SDK (Node):

const session = await verify.sessions.create({ workflowId: process.env.VALYD_WORKFLOW_ID!, // license-only OR kyc+license workflow redirectUrl: "https://app.example.com/verify/callback", callback: "https://api.example.com/webhooks/valyd", vendorData: "user_123", // your internal ref — echoed back on the webhook ttlSeconds: 900, metadata: { plan: "pro" }, }); // session.url → redirect the user here // session.session_id, session.session_token, session.expires_at

Expected output: HTTP 200 with the envelope { success: true, data: { … } }. The data object:

{ "session_id": "ses_…", "status": "NOT_STARTED", "url": "https://idp.valyd.work/s/…", "session_token": "stk_…", "features": ["id_verification","liveness","face_match","credential"], "redirect_url": "https://app.example.com/verify/callback", "expires_at": "2026-06-11T12:00:00Z" }

Placeholder source: wf_… / VALYD_WORKFLOW_ID is the workflow_id from the Developer Portal (https://dev.valyd.work  → Workflows). $VALYD_API_KEY is your App API key from the same console.

Step 2 — Redirect the user to the hosted page

Send the user’s browser to data.url. Valyd handles the entire capture and verification UI; the steps auto-adapt to the workflow’s services.

// Express app.post("/start-verification", 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.user.id, }); res.redirect(session.url); });

Expected output: an HTTP 302 redirect of the user’s browser to the Valyd-hosted session.url (under https://idp.valyd.work/s/…).

Step 3 — Handle the redirect back

When the user finishes (or abandons), Valyd redirects to redirect_url?session_id=…&status=…. Treat status as a HINT ONLY — always fetch the authoritative decision in step 4.

app.get("/verify/callback", async (req, res) => { const { session_id } = req.query; // Don't trust ?status= — confirm via the decision API or webhook. res.redirect(`/verify/pending?s=${session_id}`); });

Expected output: your app receives a GET on redirect_url with query params session_id and status; you redirect the user to a pending/result page and do NOT trust the status query param as final.

IF you trust the ?status= query param as final: → DON'T. It is a hint only. IF you need the final outcome: → fetch it from the decision API (Step 4) or from the signed webhook (below).

Step 4 — Get the authoritative result

Use the signed webhook (push) and/or call the decision endpoint (pull). See “Webhooks” and “Reading the decision” below.

Verification

To confirm the integration end-to-end, after a session reaches a terminal status:

curl https://idp.valyd.work/api/v2/session/ses_…/decision \ -H "X-API-Key: $VALYD_API_KEY"

Expected output: HTTP 200 with data.status one of "APPROVED" | "DECLINED" | "IN_REVIEW" and a data.checks array, confirming the session was processed.

Common errors

  1. Invalid webhook signature

    • Cause: verifying the signature against a re-serialized JSON body, or using the wrong signing secret.
    • Fix: verify against the RAW request body (no JSON re-serialisation) using express.raw({ type: "application/json" }), and use the correct VALYD_WEBHOOK_SECRET. The SDK throws ValydVerifyError with code === "invalid_signature"; return HTTP 400.
  2. Trusting the redirect ?status= as the final decision

    • Cause: reading the status query param on redirect_url and treating it as authoritative.
    • Fix: treat it as a hint only; confirm via the webhook or the decision endpoint.
  3. Exposing the API key to the browser

    • Cause: calling /api/v2/session (or any server-to-server endpoint) from frontend code.
    • Fix: keep X-API-Key server-side only. Only the hosted url and the session_token are sent to the browser.

Webhooks

On a terminal session, Valyd POSTs to your callback URL with these headers:

  • X-Valyd-Timestamp — unix seconds the request was signed.
  • X-Valyd-Event-Id — unique event id (use to dedupe).
  • X-Valyd-Signaturehmac_sha256(${timestamp}.${rawBody}, webhook_signing_secret), lowercase hex.

Verify against the RAW request body (no JSON re-serialisation), use a constant-time compare, and reject stale timestamps (e.g. > 5 minutes old).

Webhook body:

{ "event_id": "evt_…", "type": "verification.approved", // verification.declined | .in_review | .abandoned | .expired "session_id": "ses_…", "status": "APPROVED", "vendor_data": "user_123", "decision": { /* same shape as the decision API */ }, "occurred_at": "2026-06-11T12:05:00Z" }

Event type values: verification.approved, verification.declined, verification.in_review, verification.abandoned, verification.expired.

SDK (Node) — verify and handle the webhook:

import express from "express"; import { VerifyClient, ValydVerifyError } from "@valyd/sdk"; const verify = new VerifyClient({ apiKey: process.env.VALYD_API_KEY!, webhookSecret: process.env.VALYD_WEBHOOK_SECRET!, }); // IMPORTANT: use raw body 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.session_id, event.type, event.status, event.decision, event.vendor_data await persistDecision(event); res.json({ ok: true }); } catch (err) { if (err instanceof ValydVerifyError && err.code === "invalid_signature") { return res.status(400).send("bad signature"); } throw err; } } );

Manual verify (Node) — verify the signature without the SDK:

import crypto from "node:crypto"; function verifyValydSignature(rawBody, headers, secret) { const ts = headers["x-valyd-timestamp"]; const sig = headers["x-valyd-signature"]; if (!ts || !sig) throw new Error("missing signature headers"); // Reject stale (> 5 min) if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) { throw new Error("stale timestamp"); } const expected = crypto .createHmac("sha256", secret) .update(`${ts}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(sig, "hex"); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { throw new Error("invalid signature"); } }

The webhook is a NOTIFICATION. For the full check breakdown, call the decision endpoint below.


Reading the decision

Decision tree for reading the verification decision:

IF you received a webhook: → it carries event.status and event.decision; still call the decision API for the full check breakdown. IF you want to pull the result yourself: → GET /api/v2/session/<session_id>/decision with X-API-Key. Then read d.status: IF d.status == "APPROVED": → verification succeeded (see the per-check rule below for KYC + License). IF d.status == "DECLINED": → verification failed; inspect d.checks for the failing check's error. IF d.status == "IN_REVIEW": → a human/manual review is pending; wait for a terminal webhook or poll again. Per check in d.checks (each: { type, status, score, data, error }): IF check.status == "passed": → this check succeeded. IF check.status == "failed": → read check.error?.message (e.g. "License belongs to a different name"). IF check.status == "review": → this check is awaiting manual review. IF check.status == "pending" or "running": → not finished yet.

cURL:

curl https://idp.valyd.work/api/v2/session/ses_…/decision \ -H "X-API-Key: $VALYD_API_KEY"

SDK (Node):

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

Expected output — HTTP 200, data:

{ "status": "APPROVED", "decision": "APPROVED", "decided_at": "2026-06-11T12:05:00Z", "checks": [ { "type": "id_verification", "status": "passed", "score": 0.97, "data": { /* fields, portrait, … */ } }, { "type": "liveness", "status": "passed", "score": 1.00, "data": { "live_score": 1 } }, { "type": "face_match", "status": "passed", "score": 0.97, "data": { "similarity": 0.97, "threshold": 0.95 } }, { "type": "credential", "status": "passed", "score": 1.00, "data": { "match": true, "license": { "status": "active", "expires_at": "2027-01-01" } } } ] }

APPROVED for KYC + License means ALL of:

  • The ID was verified (OCR + authenticity).
  • The selfie was live.
  • The selfie matches the ID portrait.
  • The license exists AND belongs to the person on the ID.

Other session & workflow helpers

// Sessions const session = await verify.sessions.retrieve(sessionId); const page = await verify.sessions.list({ status: "APPROVED", vendorData: "user_123", limit: 50 }); await verify.sessions.updateStatus(sessionId, "APPROVED"); // or "DECLINED" — manual override // Workflows (↔ /api/v2/workflows) const wf = await verify.workflows.create({ name: "KYC + License", services: ["id_verification", "liveness", "face_match", "credential"], }); await verify.workflows.list(); await verify.workflows.retrieve(wf.id); await verify.workflows.update(wf.id, { name: "KYC + License (v2)" }); await verify.workflows.remove(wf.id);

Statuses

Session status

NOT_STARTED → IN_PROGRESS → (IN_REVIEW) → APPROVED | DECLINED

Plus terminal: ABANDONED, EXPIRED.

Full set of session status values: NOT_STARTED, IN_PROGRESS, IN_REVIEW, APPROVED, DECLINED, ABANDONED, EXPIRED.

Check status

pending → running → passed | failed | review

Full set of check status values: pending, running, passed, failed, review.


API reference (endpoints used in this flow)

MethodPathAuthPurpose
POSThttps://idp.valyd.work/api/v2/sessionX-API-KeyCreate a hosted verification session.
GEThttps://idp.valyd.work/api/v2/session/<session_id>/decisionX-API-KeyRead the authoritative decision and per-check breakdown.
GET (SDK: sessions.retrieve)https://idp.valyd.work/api/v2/session/<session_id>X-API-KeyRetrieve a session.
GET (SDK: sessions.list)https://idp.valyd.work/api/v2/sessionX-API-KeyList sessions (filter by status, vendorData, limit).
POST/PATCH (SDK: sessions.updateStatus)https://idp.valyd.work/api/v2/session/<session_id>X-API-KeyManually override session status to APPROVED or DECLINED.
POST (SDK: workflows.create)https://idp.valyd.work/api/v2/workflowsX-API-KeyCreate a workflow.
GET (SDK: workflows.list)https://idp.valyd.work/api/v2/workflowsX-API-KeyList workflows.
GET (SDK: workflows.retrieve)https://idp.valyd.work/api/v2/workflows/<id>X-API-KeyRetrieve a workflow.
PATCH (SDK: workflows.update)https://idp.valyd.work/api/v2/workflows/<id>X-API-KeyUpdate a workflow.
DELETE (SDK: workflows.remove)https://idp.valyd.work/api/v2/workflows/<id>X-API-KeyDelete a workflow.
POST (your endpoint)<your callback URL>Signed via X-Valyd-SignatureWebhook Valyd POSTs to on a terminal session.

The exact HTTP methods/paths for the SDK helpers sessions.retrieve, sessions.list, sessions.updateStatus, and the workflows.* helpers are inferred from the SDK method names and the noted ↔ /api/v2/workflows mapping in the source; the component only spells out the literal paths POST /api/v2/session and GET /api/v2/session/<id>/decision.


End-to-end Express example

import express from "express"; import { VerifyClient, ValydVerifyError } from "@valyd/sdk"; const app = express(); const verify = new VerifyClient({ apiKey: process.env.VALYD_API_KEY!, webhookSecret: process.env.VALYD_WEBHOOK_SECRET!, }); // 1) Start hosted verification app.post("/start-verification", express.json(), async (req, res) => { const session = await verify.sessions.create({ workflowId: process.env.VALYD_WORKFLOW_ID!, // license-only OR kyc+license redirectUrl: `${process.env.APP_URL}/verify/callback`, callback: `${process.env.APP_URL}/webhooks/valyd`, vendorData: req.body.userId, }); res.json({ url: session.url, sessionId: session.session_id }); }); // 2) Redirect-back (status is a hint only) app.get("/verify/callback", (req, res) => { res.redirect(`/verify/pending?s=${req.query.session_id}`); }); // 3) Signed webhook — MUST use raw body app.post( "/webhooks/valyd", express.raw({ type: "application/json" }), async (req, res) => { try { const event = verify.webhooks.constructEvent(req.body, req.headers); // 4) Pull the full decision (webhook is a notification) const decision = await verify.sessions.decision(event.session_id); await persist(event.vendor_data, decision); res.json({ ok: true }); } catch (err) { if (err instanceof ValydVerifyError && err.code === "invalid_signature") { return res.status(400).send("bad signature"); } throw err; } } ); app.listen(3000);