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
Verified once on the clinician’s Valyd account. Reused after — never re-done.
Identity (KYC)Checked live against the state board, then stored on the account and re-checked over time.
Medical licenseA live selfie matched against the clinician’s stored Valyd face vector.
Face at the doorA real GPS fix is mandatory. Inside the geofence → passed; outside it → failed.
LocationThe 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 scopesprofile,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 itsworkflow_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 urlNo 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.
One hosted session
Valyd hosts the capture page for the whole EVV workflow. Already-verified clinicians skip KYC + license and do only the face scan.
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 });
});// 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 + decisionReading 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)
// 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).
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.