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);
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);
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 [email protected]
sf org assign permset --name Veryfi_Webhook_Service --on-behalf-of [email protected]

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 CodeVeryfiQRService.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