Skip to main content

Salesforce QR Capture Guide

A Lightning Web Component that lets an authenticated Salesforce user generate a Veryfi QR code, capture a document on their phone, and see the OCR'd document JSON on the page when processing completes.

Copy the source below into a Salesforce DX project — no separate bundle download required. For QR API details (headers, modes, webhooks), see the QR Capture Guide.

info

Prerequisites:

  • Veryfi CLIENT ID, USERNAME, and API KEY from Keys
  • Lens For Browser enabled for your account (confirm with Customer Support)
  • Salesforce CLI (sf --version) authenticated to your target org (sf org login web)

Project layout​

Create these paths under your DX project root:

force-app/main/default/
├── classes/
│ ├── VeryfiLensConfig.cls
│ ├── VeryfiLensConfig.cls-meta.xml
│ ├── VeryfiQRService.cls
│ ├── VeryfiQRService.cls-meta.xml
│ ├── VeryfiWebhookEndpoint.cls
│ └── VeryfiWebhookEndpoint.cls-meta.xml
├── lwc/veryfiQrCapture/
│ ├── veryfiQrCapture.js
│ ├── veryfiQrCapture.html
│ ├── veryfiQrCapture.css
│ └── veryfiQrCapture.js-meta.xml
├── objects/Veryfi_Capture__c/
│ ├── Veryfi_Capture__c.object-meta.xml
│ └── fields/
│ ├── External_Id__c.field-meta.xml
│ ├── Status__c.field-meta.xml
│ ├── Document_Id__c.field-meta.xml
│ ├── Document_Type__c.field-meta.xml
│ └── Document_Json__c.field-meta.xml
├── permissionsets/
│ ├── Veryfi_QR_Capture_User.permissionset-meta.xml
│ └── Veryfi_Webhook_Service.permissionset-meta.xml
└── remoteSiteSettings/
├── Veryfi_Lens.remoteSite-meta.xml
└── Veryfi_API.remoteSite-meta.xml

Source code​

classes/VeryfiLensConfig.cls​

Replace the three credential placeholders with your Veryfi keys.

/**
* Central credentials for the Veryfi Lens integration.
* Replace the three placeholder values below with your Veryfi credentials
* (from the Veryfi dashboard → Settings → Keys).
*
* For production, consider moving these to a Named Credential, Custom Metadata
* Type, or protected Custom Setting so they aren't in source control.
*/
public class VeryfiLensConfig {
public static final String CLIENT_ID = 'YOUR_VERYFI_CLIENT_ID';
public static final String API_KEY = 'YOUR_VERYFI_API_KEY';
public static final String USERNAME = 'YOUR_VERYFI_USERNAME';

public static final String LENS_BASE_URL = 'https://lens.veryfi.com';
public static final String API_BASE_URL = 'https://api.veryfi.com';
}

classes/VeryfiLensConfig.cls-meta.xml​

<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>66.0</apiVersion>
<status>Active</status>
</ApexClass>

classes/VeryfiQRService.cls​

/**
* Server-side support for the Veryfi QR Capture LWC.
*
* generateQR() — creates a Veryfi QR session, returns the QR + deep link + external_id.
* fetchCapture() — looks up the Veryfi_Capture__c record the webhook inserted.
*
* Sharing: declared `with sharing` so fetchCapture respects record ownership. Each user
* only sees captures they own (records are owned by the user who generated the QR,
* via OwnerId parsed from the external_id by VeryfiWebhookEndpoint).
*/
public with sharing class VeryfiQRService {

public class QrResult {
@AuraEnabled public String qrUrl;
@AuraEnabled public String linkUrl;
@AuraEnabled public String sessionId;
@AuraEnabled public String externalId;
}

@AuraEnabled
public static QrResult generateQR() {
try {
String externalId = buildExternalId();

HttpRequest req = new HttpRequest();
req.setEndpoint(VeryfiLensConfig.LENS_BASE_URL + '/api/generate_qr');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('CLIENT-ID', VeryfiLensConfig.CLIENT_ID);
// Standard (legacy) key. For a client-scoped Bearer key (full-access or scoped) use: 'Bearer ' + VeryfiLensConfig.API_KEY
req.setHeader('AUTHORIZATION', 'apikey ' + VeryfiLensConfig.USERNAME + ':' + VeryfiLensConfig.API_KEY);

Map<String, Object> body = new Map<String, Object>{
'client_id' => VeryfiLensConfig.CLIENT_ID,
'external_id' => externalId,
'mode' => 'document',
'expires_in' => 3600,
'is_async' => true
};
req.setBody(JSON.serialize(body));
req.setTimeout(60000);

HttpResponse res = new Http().send(req);
if (res.getStatusCode() < 200 || res.getStatusCode() >= 300) {
throwAura('Veryfi API error ' + res.getStatusCode() + ': ' + res.getBody());
}

Map<String, Object> data = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
QrResult result = new QrResult();
result.qrUrl = (String) data.get('qr_code_base64');
result.linkUrl = (String) data.get('url');
result.sessionId = (String) data.get('session_id');
result.externalId = (String) data.get('external_id');
return result;
} catch (AuraHandledException e) {
throw e;
} catch (Exception e) {
throwAura(e.getTypeName() + ': ' + e.getMessage());
return null;
}
}

@AuraEnabled
public static Veryfi_Capture__c fetchCapture(String externalId) {
List<Veryfi_Capture__c> records = [
SELECT Id, External_Id__c, Status__c, Document_Id__c, Document_Type__c, Document_Json__c
FROM Veryfi_Capture__c
WHERE External_Id__c = :externalId
LIMIT 1
];
return records.isEmpty() ? null : records[0];
}

/**
* external_id format: sf-<userId>-<timestampMs>-<16hex>
* - userId → so the webhook can attribute the record to the right SF user
* - timestampMs → ordering / dedupe within a session
* - 16 random hex → entropy so an attacker can't guess another user's id
*/
private static String buildExternalId() {
String randomHex = EncodingUtil.convertToHex(Crypto.generateAesKey(128)).substring(0, 16);
return 'sf-' + UserInfo.getUserId()
+ '-' + String.valueOf(Datetime.now().getTime())
+ '-' + randomHex;
}

private static void throwAura(String msg) {
AuraHandledException ex = new AuraHandledException(msg);
ex.setMessage(msg);
throw ex;
}
}

classes/VeryfiQRService.cls-meta.xml​

<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>66.0</apiVersion>
<status>Active</status>
</ApexClass>

classes/VeryfiWebhookEndpoint.cls​

/**
* REST endpoint Veryfi POSTs to when a captured document finishes processing.
*
* Veryfi POSTs a minimal event payload ({event, data: {id}}). We fetch the full
* document via Veryfi's API, parse the originating Salesforce user id out of the
* external_id, and insert a Veryfi_Capture__c record owned by that user.
*
* Sharing: `without sharing` so we can set OwnerId at insert time regardless of
* the running user's sharing.
*/
@RestResource(urlMapping='/veryfi/webhook/*')
global without sharing class VeryfiWebhookEndpoint {

@HttpPost
global static void receive() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
res.addHeader('Content-Type', 'application/json');

try {
String body = req.requestBody != null ? req.requestBody.toString() : '';
Map<String, Object> payload = (Map<String, Object>) JSON.deserializeUntyped(body);
String eventName = (String) payload.get('event');
Map<String, Object> data = (Map<String, Object>) payload.get('data');

if (data == null || data.get('id') == null) {
res.statusCode = 200;
res.responseBody = Blob.valueOf('{"status":"ignored"}');
return;
}

String docId = String.valueOf(data.get('id'));
String prefix = (eventName != null && eventName.contains('.'))
? eventName.substringBefore('.')
: 'document';

Map<String, Object> doc = fetchDocument(prefix, docId);

String externalId = extractExternalId(doc);
Id ownerId = parseOwnerId(externalId);

if (ownerId == null) {
res.statusCode = 200;
res.responseBody = Blob.valueOf('{"status":"unparseable_external_id"}');
return;
}

Veryfi_Capture__c rec = new Veryfi_Capture__c(
External_Id__c = externalId,
OwnerId = ownerId,
Document_Id__c = docId,
Document_Type__c = prefix,
Document_Json__c = JSON.serialize(doc),
Status__c = 'Captured'
);

try {
insert rec;
} catch (DmlException dex) {
if (dex.getMessage().contains('DUPLICATE_VALUE')) {
res.statusCode = 200;
res.responseBody = Blob.valueOf('{"status":"already_captured"}');
return;
}
throw dex;
}

res.statusCode = 200;
res.responseBody = Blob.valueOf('{"status":"ok"}');
} catch (Exception e) {
res.statusCode = 500;
res.responseBody = Blob.valueOf('{"status":"error","message":' + JSON.serialize(e.getMessage()) + '}');
}
}

private static Map<String, Object> fetchDocument(String prefix, String docId) {
String endpoint = (prefix == 'document') ? 'documents' : 'any-documents';

HttpRequest httpReq = new HttpRequest();
httpReq.setEndpoint(VeryfiLensConfig.API_BASE_URL + '/api/v8/partner/' + endpoint + '/' + docId);
httpReq.setMethod('GET');
httpReq.setHeader('CLIENT-ID', VeryfiLensConfig.CLIENT_ID);
// Standard (legacy) key. For a client-scoped Bearer key (full-access or scoped) use: 'Bearer ' + VeryfiLensConfig.API_KEY
httpReq.setHeader('AUTHORIZATION', 'apikey ' + VeryfiLensConfig.USERNAME + ':' + VeryfiLensConfig.API_KEY);
httpReq.setTimeout(60000);

HttpResponse httpRes = new Http().send(httpReq);
if (httpRes.getStatusCode() < 200 || httpRes.getStatusCode() >= 300) {
throw new CalloutException('Veryfi document fetch ' + httpRes.getStatusCode() + ': ' + httpRes.getBody());
}
return (Map<String, Object>) JSON.deserializeUntyped(httpRes.getBody());
}

private static String extractExternalId(Map<String, Object> doc) {
if (doc.containsKey('meta') && doc.get('meta') instanceof Map<String, Object>) {
Object v = ((Map<String, Object>) doc.get('meta')).get('external_id');
if (v != null) return String.valueOf(v);
}
Object v2 = doc.get('external_id');
return v2 == null ? null : String.valueOf(v2);
}

private static Id parseOwnerId(String externalId) {
if (externalId == null || !externalId.startsWith('sf-')) {
return null;
}
String tail = externalId.substring(3);
Integer dashIdx = tail.indexOf('-');
if (dashIdx <= 0) {
return null;
}
String userIdStr = tail.substring(0, dashIdx);
try {
return Id.valueOf(userIdStr);
} catch (Exception e) {
return null;
}
}
}

classes/VeryfiWebhookEndpoint.cls-meta.xml​

<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>66.0</apiVersion>
<status>Active</status>
</ApexClass>

Org prerequisites​

Before deploying, confirm the target org has:

  • My Domain enabled (Setup → My Domain). Required for OAuth and the webhook URL.
  • Lightning Experience enabled.
  • API access at the org level (Enterprise/Unlimited; not all Developer Edition / Essentials orgs).
  • An internal user with API Enabled on their profile to act as the webhook integration user. Customer Community profiles do not qualify.

Deploy​

After copying the files above and setting credentials in VeryfiLensConfig.cls:

sf project deploy start --source-dir force-app/main/default
info

Production orgs require ≥75% Apex test coverage. Add a test class before production deploy. See Production deployment. Sandbox and Developer Edition orgs deploy without tests.

Assign permission sets​

Perm setAssign to
Veryfi_QR_Capture_UserEvery end user who will use the QR component
Veryfi_Webhook_ServiceThe integration user Veryfi authenticates as for webhooks

The integration user's profile must also have API Enabled (profile-level; perm sets cannot grant this).

sf org assign permset --name Veryfi_QR_Capture_User --on-behalf-of someone@example.com
sf org assign permset --name Veryfi_Webhook_Service --on-behalf-of webhook-integration@example.com

Webhook authentication (Connected App + OAuth)​

Salesforce REST endpoints require OAuth. Veryfi's webhook must send Authorization: Bearer <token>.

  1. Salesforce: Setup → App Manager → New Connected App

    • Name: Veryfi Webhook
    • Enable OAuth Settings: ✓
    • Callback URL: https://localhost (required but unused)
    • OAuth Scopes: Manage user data via APIs (api) + Perform requests at any time (refresh_token, offline_access)
    • Enable Client Credentials Flow: ✓
    • Run As: the integration user with Veryfi_Webhook_Service
    • Save and wait 2–10 minutes for propagation
  2. Copy the Connected App Consumer Key and Consumer Secret.

  3. In the Veryfi dashboard, configure the webhook:

    • Webhook URL: https://<YOUR_MY_DOMAIN>.my.salesforce.com/services/apexrest/veryfi/webhook
    • OAuth token URL: https://<YOUR_MY_DOMAIN>.my.salesforce.com/services/oauth2/token
    • Client ID / Client Secret: Consumer Key / Consumer Secret
    • Grant type: client_credentials
  4. Test: trigger a capture from the LWC, then verify:

sf data query --query "SELECT Name, External_Id__c, OwnerId, Status__c, CreatedDate FROM Veryfi_Capture__c ORDER BY CreatedDate DESC LIMIT 5"

Place the LWC on a page​

Lightning Experience: Setup → Lightning App Builder → drag Veryfi QR from Custom onto a page → Save → Activate.

Experience Cloud: Experience Builder → drag Veryfi QR from Custom Components → Publish.

How it works​

  1. User clicks Generate QR Code → VeryfiQRService.generateQR() POSTs to /api/generate_qr.
  2. User scans the QR or taps Capture → Veryfi's capture PWA opens on their phone.
  3. Veryfi POSTs to /services/apexrest/veryfi/webhook → endpoint fetches the document and inserts Veryfi_Capture__c owned by the QR-generating user.
  4. The LWC polls fetchCapture(externalId) every 2 seconds and renders the document JSON.

Privacy model​

  • Veryfi_Capture__c uses private sharing — each record is owned by the user who generated the QR.
  • VeryfiQRService is with sharing; VeryfiWebhookEndpoint is without sharing (insert-only, sets OwnerId from external_id).

Production deployment​

Add an Apex test class with ≥75% coverage of VeryfiQRService and VeryfiWebhookEndpoint before deploying to production. Stub HTTP callouts with HttpCalloutMock and simulate REST requests via RestContext.

Customization​

WhatWhere
Page typesveryfiQrCapture.js-meta.xml <targets>
Polling interval / timeoutPOLL_INTERVAL_MS, POLL_TIMEOUT_MS in veryfiQrCapture.js
Capture mode'mode' in VeryfiQRService.generateQR body (default 'document') — see QR Capture Guide
Structured fieldsParse Document_Json__c in a trigger or flow
CredentialsMove from VeryfiLensConfig.cls to Named Credential or Custom Metadata

Troubleshooting​

SymptomFix
Insufficient access rights on cross-reference id on generateQRAssign Veryfi_QR_Capture_User
Webhook 401 INVALID_SESSION_IDCheck Connected App Run As user, API Enabled, Client Credentials grant
Webhook 403 INSUFFICIENT_ACCESS_OR_READONLYAssign Veryfi_Webhook_Service to integration user
Records created but LWC never shows themSharing — confirm OwnerId matches polling user
Stuck on "Waiting for capture..."Check Debug Logs; verify Veryfi webhook delivery
Unauthorized endpoint from ApexDeploy both Remote Site Settings

Next steps​