Skip to Content
Electronic Visit Verification (EVV)

Electronic Visit Verification

Verification APIs · EVV

Prove the right clinician reached the right home — with verified identity, a live medical license, a face match, and geolocation. Built on Reusable Verification, so clinicians verify once and reuse that identity on every visit — each visit still gets a fresh face + location check.

Live demo: homehealth.valyd.work  · admin + clinician portals, both flows wired end-to-end.

What an EVV visit proves

The flow in four steps

Connect Valyd

Clinician logs in — your backend gets their access token.

Create session

Pass the token + workflow to Verify → binds the valyd_id.

Capture

Returning users just do the face + GPS in the hosted modal.

Read decision

Your backend reads the result by session id (API key).

Before you start

1 · Get your keys

One console. Sign in at dev.valyd.work  — the dev portal issues everything. There is no separate Verify console.

  • OAuth client (for “Connect Valyd”) — register an app → client_id + client_secret; add your redirect URI and the scopes profile, verifications, doctor_license.
  • Verify API key + workflow — in the same portal, open your app’s Verification tab → copy the API key (vrf_…, shown once) + webhook secret. Then New workflow → “Home Health · EVV” (pre-selects ID, liveness, face match, license & location) → copy its workflow_id.

Keep client_secret and the API key server-side only.

2 · Install the SDKs

Server (Node 18+) and browser:

# server: OAuth + Verify (auth, sessions) npm i @valyd/sdk # browser: none — the face + GPS are captured on Valyd's verification page # No browser SDK — the verification session is a redirect to the session url

No bundler? There is no browser SDK to include — the verification session works by redirecting the user to Valyd’s verification page at the session url.

Quickstart

One-time setup, one console: at dev.valyd.work  register an app (get client_id, client_secret, API key) and build a workflow — pick the “Home Health · EVV” campaign (ID + liveness + face_match + credential + location) → workflow_id.

Both modes start the same way — “Connect Valyd”. The clinician logs in with Valyd (OAuth), and your server gets their access token via exchangeCode. That token is what identifies the person, gates KYC, and unlocks license/identity reuse. EVV runs on Reusable Verification — the sensitive checks (ID/KYC, license, face match, location) run on Valyd’s hosted session with that token, never as self-serve direct calls.

Valyd hosts the capture page for the whole EVV workflow. Already-verified clinicians skip KYC + license and do only the face scan.

Backend (Node) — Connect Valyd → session → decision
import { Valyd } from "@valyd/sdk"; const valyd = new Valyd({ clientId, clientSecret, apiKey, webhookSecret, env: "development", // → idp.valyd.work (login + Verify) }); // 1) "Connect Valyd" — log the clinician in (OAuth) app.get("/evv/login", (req, res) => res.redirect(valyd.auth.getAuthorizationUrl({ scope: ["profile", "verifications", "doctor_license"] }))); // 2) On callback, create a hosted EVV session bound to their Valyd identity app.get("/evv/callback", async (req, res) => { const { accessToken, user } = await valyd.auth.exchangeCode(req.query.code); const session = await valyd.verify.sessions.create({ workflowId: EVV_WORKFLOW_ID, // id + liveness + face_match + credential + location valydAccessToken: accessToken, // ← identifies the person (→ valyd_id) vendorData: user.valyd_id, metadata: { expected_lat: home.lat, expected_lng: home.lng }, // the assigned home }); res.json({ url: session.url }); // returning, verified users → just a face scan }); // 3) Get notified + read the result server-side (source of truth) app.post("/webhooks/valyd", express.raw({ type: "*/*" }), async (req, res) => { const event = valyd.verify.webhooks.constructEvent(req.body, req.headers); // verifies signature const decision = await valyd.verify.sessions.decision(event.sessionId); if (decision.status === "APPROVED") markVisitVerified(event.vendorData, decision); res.json({ ok: true }); });
Browser — the Connect button + the modal
// No browser SDK — "Connect Valyd" and the hosted flow are both redirects. // 1) "Connect Valyd" is a plain link to your OAuth login: // <a href="/evv/login">Connect Valyd</a> // 2) Once connected, your server creates a session and returns its url: const { url } = await fetch("/evv/session").then(r => r.json()); window.location.href = url; // Valyd hosts the capture; result via webhook + decision

Reading the result

Webhooks are optional. Two ways to get the outcome — pick either. Both end at the same authoritative SDK call: valyd.verify.sessions.decision(id). The decision is the source of truth; the browser status and the webhook are just signals to go read it.

Option A · Poll (no webhook)

Simplest. Read the decision when the user returns / the modal completes.

// NO WEBHOOK NEEDED — read the result when the user returns. // Browser: after the modal completes, ask your server for the decision. await open({ url, onComplete: async ({ sessionId }) => { const r = await fetch("/evv/result/" + sessionId).then(r => r.json()); console.log(r.status, r.checks); // your /evv/result route calls sessions.decision(id) }}); // Server: GET /evv/result/:id app.get("/evv/result/:id", async (req, res) => res.json(await valyd.verify.sessions.decision(req.params.id)));

Option B · Webhook (push)

More reliable (fires even if the user closes the tab). Configured per app in the console, or per session via callback.

// OPTIONAL. Webhooks are set per APP in the console (or per session via `callback`). // The event is only a NOTIFICATION — not the full result: { "type": "verification.completed", "session_id": "ses_8f…", "status": "APPROVED", // APPROVED | DECLINED | IN_REVIEW | EXPIRED | ABANDONED "vendor_data": "valyd_225c7f2ac450496f97bbbc57354a5898", "occurred_at": "2026-07-01T18:04:11Z" } // Signature: HMAC-SHA256 over "timestamp.rawBody" — verify it with // valyd.verify.webhooks.constructEvent(rawBody, headers) before trusting it.

The decision response (what a verification returns)

sessions.decision(id)
// valyd.verify.sessions.decision(id) // This is the authoritative full result (works with OR without webhooks): { "session_id": "ses_8f…", "status": "APPROVED", "vendor_data":"valyd_225c7f2ac450496f97bbbc57354a5898", "valyd_id": "valyd_225c7f2ac450496f97bbbc57354a5898", "checks": [ { "type": "id_verification", "status": "passed", "data": { "reused": true } }, { "type": "liveness", "status": "passed" }, { "type": "face_match", "status": "passed", "score": 0.98 }, { "type": "credential", "status": "passed", "data": { "license": { "status": "active" } } }, // location: a real GPS fix is mandatory. An expected point + radius_m was given, so the // status IS the verdict — "failed" (with an error message) if the clinician is outside it. { "type": "location", "status": "passed", "data": { "distance_m": 12, "radius_m": 200, "match": true } } ], "identity": { "full_name": "Grace Lee Casado", "licenses": [ { "license_state": "CO", "status": "active", "expire_date": "2027-01-01" } ] }, "decided_at": "2026-07-01T18:04:10Z" }

status is the overall outcome; checks[] has one entry per check (with score/data); identity carries the reusable profile + licenses. reused: true marks steps skipped from the Valyd account.

Integrate with your AI assistant

Copy this prompt into Claude, Cursor, Copilot or any coding AI. It has the SDKs, the credentials to ask for, the rules, and both flows — the assistant will scaffold the integration in your stack. The URLs below target the development environment (idp.valyd.work).

Prompt — paste into your AI assistant (development)
You are integrating Verification APIs — EVV (Electronic Visit Verification) into my app. Valyd proves the right, licensed clinician is physically at the right patient's home: verified identity (KYC) + live medical license + face match + geolocation. It uses the Reusable Verification (account) model — the clinician connects with Valyd once; their KYC and license are stored and reused on later visits. SDKs (install): - Server (Node 18+): npm i @valyd/sdk // valyd.auth (OIDC) + valyd.verify (checks) - Browser: no SDK — redirect the user to the hosted session url (Valyd hosts the capture) Environment (IMPORTANT): you are on DEVELOPMENT — construct the SDK with env="development": new Valyd({ clientId, clientSecret, apiKey, env: "development" }) This targets idp.valyd.work (login) + idp.valyd.work (Verify) + KYC. WITHOUT env the SDK defaults to PRODUCTION (valyd.id) and OAuth fails with "client_id/redirect_uri not allowed". One env switch sets IdP + Verify + KYC. Credentials — ALL from ONE console, the dev portal at dev.valyd.work (there is no separate Verify console). Ask me for these; keep all server-side, never in the browser: - VALYD_CLIENT_ID / VALYD_CLIENT_SECRET — your OAuth app - VALYD_API_KEY / VALYD_WEBHOOK_SECRET — your app's verification settings (API key vrf_…, shown once) - VALYD_WORKFLOW_ID — a "Home Health · EVV" workflow (id+liveness+face_match+credential+location) Rules: - new Valyd({...}) generates nothing — it only holds config; env picks the environment URLs. - Get the Valyd token with valyd.auth.exchangeCode(code) AFTER the user logs in. - Pass that token to sessions.create({ valydAccessToken }) — it goes in the SESSION, not the workflow; it identifies the person (valyd_id) and unlocks KYC/license reuse. - KYC is NOT a self-serve call: if the account is missing id_verified, run a KYC workflow session (sessions.create({ workflowId, valydAccessToken, redirectUrl })) and redirect to session.url. The user completes KYC on Valyd's hosted page and returns. - Expected (patient-home) location is passed PER SESSION via metadata.expected_lat / expected_lng (+ radius_m). - Face + GPS are captured on Valyd's HOSTED session page — there is no browser SDK and no direct check calls. - LOCATION SEMANTICS: a real GPS fix is ALWAYS mandatory — it can never be skipped, and a blocked permission or missing coordinates is a hard "failed". If you pass an expected point AND radius_m, the STATUS IS THE VERDICT: "passed" inside the radius, "failed" outside it (data.match true/false, data.distance_m the distance). Expected point but NO radius -> "passed" with data.match === null and only distance_m reported (you decide). No expected point -> capture-only "passed" with the coordinates. Do NOT treat location as report-only / always-passing. - KYC + license are ONE-TIME onboarding steps (redirect to Valyd — they run on the hosted page, never as direct calls). Do NOT put a KYC/license button on every visit. The recurring visit is a lean verification session with just face_match + location (ID/license reused from the account). - The recurring visit is a sessions.create({ workflowId, valydAccessToken, metadata }) call; send the clinician to session.url and read sessions.decision(id). No self-serve check calls. - ACCOUNT face = selfie only, captured on the hosted page and matched to the stored Valyd vector; never ask the user for an ID/reference image. - Webhooks are OPTIONAL. Default = poll sessions.decision(id) when the user returns. Add a webhook (constructEvent + decision) only if you want push/extra reliability (fires even if the user closes the tab). Flow A — One hosted session (Valyd hosts the whole workflow): 1. "Connect Valyd" button -> GET /evv/login -> res.redirect(valyd.auth.getAuthorizationUrl({ scope:["profile","verifications","doctor_license"] })) 2. GET /evv/callback?code= -> const { accessToken, user } = await valyd.auth.exchangeCode(code) 3. const s = await valyd.verify.sessions.create({ workflowId: VALYD_WORKFLOW_ID, valydAccessToken: accessToken, vendorData: user.valyd_id, metadata: { expected_lat, expected_lng }, redirectUrl, callback }); send s.url to the browser 4. Browser: redirect the user to the hosted url (window.location.href = url) — returning users do only the face scan 5. Webhook POST /webhooks/valyd: const e = valyd.verify.webhooks.constructEvent(raw, headers); const decision = await valyd.verify.sessions.decision(e.sessionId) // source of truth Flow B — Onboard once, then a lean per-visit session (still all Reusable Verification): 1. Same Connect-Valyd login/callback to get accessToken. 2. Onboarding gate: if the account is missing id_verified (await valyd.auth.getVerifications(accessToken)), run a KYC workflow session and redirect to session.url. ID/KYC and license are completed on Valyd's hosted page — never as direct calls. 3. Each visit: create a lean verification session (face + location only; id/license reused from the account): const s = await valyd.verify.sessions.create({ workflowId: EVV_VISIT_WORKFLOW_ID, valydAccessToken: accessToken, vendorData: user.valyd_id, metadata: { expected_lat, expected_lng, radius_m: 200 } }) send s.url to the browser; the face + GPS are captured on the hosted page. 4. Read the result: const decision = await valyd.verify.sessions.decision(s.id) // location: radius given => decision's location check status IS the verdict: "passed" inside, "failed" outside. Reference: docs https://docs.valyd.work/evv · live demo https://homehealth.valyd.work · Verify runs through the @valyd/sdk (env="development" → idp.valyd.work); the App key stays server-side. Now: ask me for the credentials, then scaffold the server routes + a minimal UI for BOTH flows. Put every secret in env vars and make all Valyd calls server-to-server.

Verify once, reuse on every visit

Because EVV runs on Reusable Verification, the first visit does full KYC + license; every visit after is just a face + location check. A stored license is never served past its expire_at date — an expired credential fails the check and must be re-verified — and identity/KYC live on Valyd — your app stores proofs, not documents.

Last updated on