Getting Started
Core Architecture
Link Engine
Analytics & Attribution
Partners & Affiliates
Third-Party Integrations
Identity & Security
Automation & Messaging
Developer Tools
The following files were used as context for generating this wiki page:
Identity verification in Dub utilizes the Veriff platform to authenticate partner identities, safeguard the partner network against duplicate identity fraud, and build trust with program owners. The verification system encompasses API client integrations, automated webhook decision handlers, cron verification routines, and specialized user interface components across both partner and administrative portals.
Sources: apps/web/lib/actions/partners/start-identity-verification.ts:1-89, apps/web/lib/veriff/client.ts:1-47, apps/web/app/api/veriff/webhook/handle-decision-event.ts:1-141
The Veriff integration layer provides low-level connectivity to the Veriff Station API (https://stationapi.veriff.com/v1), managing authenticated HTTP requests, HMAC signature generation for decision retrieval, and session initialization. The architecture inherits from an underlying HttpBaseClient and enforces runtime environment assertions via assertEnv to guarantee required credentials are present.
Sources: apps/web/lib/veriff/client.ts:1-47
The VeriffClient class configures base connectivity settings, including a vendor identifier, the API base URL, and response logging behavior. Authentication headers are dynamically constructed by asserting the presence of VERIFF_API_KEY.
class VeriffClient extends HttpBaseClient {
protected readonly vendor = "Veriff";
protected readonly baseUrl = "https://stationapi.veriff.com/v1";
protected readonly logResponseBodies = false;
protected buildAuthHeaders() {
return {
"X-AUTH-CLIENT": assertEnv("VERIFF_API_KEY"),
};
}
}Sources: apps/web/lib/veriff/client.ts:11-20
Important
Both VERIFF_API_KEY and VERIFF_SHARED_SECRET are asserted at runtime via assertEnv(). Omitting these environment variables will cause immediate failure during header generation or cryptographic signing.
Sources: apps/web/lib/veriff/client.ts:1-44
The client exposes methods for creating verification sessions and retrieving session decisions. The fetchSessionDecision method computes an X-HMAC-SIGNATURE header using SHA-256 over the target sessionId with the VERIFF_SHARED_SECRET.
async createSession(input: z.input<typeof veriffCreateSessionInputSchema>) {
return await this.post("/sessions", {
input,
inputSchema: veriffCreateSessionInputSchema,
outputSchema: veriffCreateSessionOutputSchema,
});
}
async fetchSessionDecision(sessionId: string) {
const hmacSignature = crypto
.createHmac("sha256", assertEnv("VERIFF_SHARED_SECRET"))
.update(sessionId)
.digest("hex");
return await this.get(`/sessions/${sessionId}/decision`, {
headers: {
"X-HMAC-SIGNATURE": hmacSignature,
},
outputSchema: veriffDecisionEventSchema,
});
}Sources: apps/web/lib/veriff/client.ts:23-44
The high-level wrapper function createVeriffSession processes partner records by splitting full names into given and family name parts, handling edge cases where only a single name token exists, and invoking veriffClient.createSession.
export async function createVeriffSession({
partner,
}: {
partner: Pick<Partner, "id" | "email" | "name">;
}) {
const nameParts = partner.name.split(" ");
const firstName = nameParts[0] || partner.name;
const lastName = nameParts.slice(1).join(" ") || partner.name;
try {
return await veriffClient.createSession({
verification: {
vendorData: partner.id,
person: {
firstName,
lastName,
},
},
});
} catch (error) {
throw new Error(
"Failed to create Veriff session. Please try again later or contact support.",
);
}
}When initiating a partner verification session, execution flows sequentially through client parsing, HTTP transport, and remote API ingestion:
createVeriffSession() receives the partner object, splits partner.name by whitespace to isolate firstName and lastName, and constructs the payload.veriffClient.createSession() accepts the Zod-validated input and delegates to this.post("/sessions") on the HttpBaseClient.buildAuthHeaders() injects the X-AUTH-CLIENT header by evaluating assertEnv("VERIFF_API_KEY").veriffCreateSessionOutputSchema.Sources: apps/web/lib/veriff/client.ts:11-45
The platform provides two distinct mechanisms for initiating Veriff identity verification sessions: a user-facing Server Action (startIdentityVerificationAction) and an administrative API route (POST /api/admin/partners/[partnerId]/generate-veriff-session). Both entry points enforce state checks, validate existing session expiration, and interact with the database using Prisma.
Sources: apps/web/lib/actions/partners/start-identity-verification.ts:16-89, apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:12-89
The user-facing server action utilizes authPartnerActionClient.action and requires the executing partner user to possess the partner_profile.update permission via throwIfNoPermission. Conversely, the administrative route uses the withAdmin middleware, restricting execution exclusively to users with the owner role.
export const startIdentityVerificationAction = authPartnerActionClient.action(
async ({ ctx }) => {
const { partner, partnerUser } = ctx;
throwIfNoPermission({
role: partnerUser.role,
permission: "partner_profile.update",
});
if (partner.identityVerificationStatus) {
switch (partner.identityVerificationStatus) {
case "approved":
throw new Error(
"Your identity has already been verified. No further action is required.",
);
case "submitted":
case "review":
throw new Error(
"A verification attempt is already in progress. Please wait for it to complete or resubmit.",
);
}
}
// ...
},
);Sources: apps/web/lib/actions/partners/start-identity-verification.ts:16-37, apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:12-48
Warning
Attempting to generate a session when identityVerificationStatus is approved, submitted, or review will immediately abort execution, throwing a validation error or returning a 400 HTTP response depending on whether the server action or admin route was invoked.
Sources: apps/web/lib/actions/partners/start-identity-verification.ts:25-37, apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:34-48
To prevent duplicate active sessions and manage external API consumption, both flows check existing metadata before provisioning a new Veriff session. If an unexpired session is found, its existing URL is returned directly.
const veriffMetadata = parseVeriffMetadata(partner.veriffMetadata);
if (
veriffMetadata.attemptCount >= MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS
) {
throw new Error(
"You've reached the maximum number of identity verification attempts. Please contact support: https://dub.co/support",
);
}
if (
partner.veriffSessionId &&
veriffMetadata.sessionUrl &&
veriffMetadata.sessionExpiresAt &&
veriffMetadata.sessionExpiresAt > new Date()
) {
return {
sessionUrl: veriffMetadata.sessionUrl,
};
}
await assertRateLimit({
policy: RATELIMIT_POLICIES.identityVerificationStart,
identifier: partner.id,
});Sources: apps/web/lib/actions/partners/start-identity-verification.ts:39-65, apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:50-64
Note
The server action enforces Upstash rate limiting using RATELIMIT_POLICIES.identityVerificationStart keyed on the partner ID, whereas the admin endpoint relies strictly on the owner role authorization wrapper.
Sources: apps/web/lib/actions/partners/start-identity-verification.ts:62-65, apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:86-89
When a session cannot be reused and passes rate limits, execution proceeds through creation and database persistence:
startIdentityVerificationAction() or POST route verifies authorization and parses partner metadata via parseVeriffMetadata().assertRateLimit() ensures the partner has not exceeded initiation quotas.createVeriffSession() is called with the partner record to obtain a new verification object containing verification.id and verification.url.prisma.partner.update() persists the new session ID and merges metadata containing a 7-day expiration calculated via addDays(new Date(), 7).Sources: apps/web/lib/actions/partners/start-identity-verification.ts:39-87, apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:50-84
Sources: apps/web/lib/actions/partners/start-identity-verification.ts:16-88, apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:12-89
The verification subsystem processes incoming Veriff decision webhooks and handles asynchronous background cron verifications for partner country changes. When Veriff posts a decision event or a cron job executes, the system resolves partner sessions, maps raw verification statuses to Prisma enums, checks for duplicate identity fraud and country mismatches, updates metadata, and dispatches transactional emails with idempotency keys.
Sources: apps/web/app/ee/api/cron/partners/verify-country-change/route.ts:21-140, apps/web/app/api/veriff/webhook/handle-decision-event.ts:32-141
Veriff webhook decision events report raw verification statuses that are translated directly into Prisma IdentityVerificationStatus enum values.
When handleDecisionEvent receives a Veriff webhook payload, it executes a strict sequence of validation, fraud evaluation, and database synchronization steps:
handleSessionDecision() extracts verification details including id, status, decisionTime, reason, attemptId, and riskLabels.prisma.partner.findUnique() searches for a partner matching veriffSessionId: id.effectiveStatus === "approved", checkCountryMismatch() and duplicate risk label checks are evaluated; if either triggers, effectiveStatus is overridden to "declined".parseVeriffMetadata() and mergeVeriffMetadata() update the attempt count, decline reason, and clear active session URLs unless resubmission_requested is set.prisma.partner.update() persists the new verification status, identityVerifiedAt timestamp, and merged metadata.sendEmailNotification() dispatches the appropriate transactional email with an idempotency header derived from attemptId.Warning
If a partner's verified document country does not match their account country during webhook processing, effectiveStatus is forcefully overridden from approved to declined, even if Veriff's automated risk engine initially approved the session.
The cron route at POST /api/cron/partners/verify-country-change periodically validates existing partner country settings against their verified Veriff session data:
export const POST = withCron(async ({ rawBody }) => {
const { partnerId } = schema.parse(JSON.parse(rawBody));
const partner = await prisma.partner.findUnique({
where: { id: partnerId },
select: {
id: true,
name: true,
email: true,
country: true,
identityVerificationStatus: true,
identityVerifiedAt: true,
veriffSessionId: true,
veriffMetadata: true,
},
});
if (!partner || !partner.veriffSessionId || !partner.country) {
return logAndRespond("Partner, session ID, or country missing. Skipping.");
}
const { verification } = await veriffClient.fetchSessionDecision(
partner.veriffSessionId,
);
const documentCountry =
(verification.document?.country || verification.person?.nationality) ?? null;
if (partner.country.toLowerCase() === documentCountry?.toLowerCase()) {
if (!partner.identityVerifiedAt) {
await prisma.partner.update({
where: { id: partner.id },
data: { identityVerifiedAt: new Date() },
});
await sendEmail({
to: partner.email!,
subject: "Your identity has been verified",
react: PartnerIdentityVerified({
partner: { name: partner.name, email: partner.email! },
}),
});
}
} else {
const declineReason =
"Your account country no longer matches your verified identity document country. Please re-verify.";
const { attemptCount } = parseVeriffMetadata(partner.veriffMetadata);
await prisma.partner.update({
where: { id: partner.id },
data: {
identityVerificationStatus: null,
identityVerifiedAt: null,
veriffSessionId: null,
veriffMetadata: mergeVeriffMetadata(partner.veriffMetadata, {
sessionUrl: null,
sessionExpiresAt: null,
declineReason,
attemptCount,
}),
},
});
await sendEmail({
to: partner.email!,
subject: "Identity re-verification required",
react: PartnerIdentityVerificationFailed({
failureType: "countryChange",
failureReasonText: declineReason,
partner: { name: partner.name, email: partner.email! },
}),
});
}
return logAndRespond("Country verification check completed.");
});Note
When a country mismatch is detected by the cron route, the partner's verification status, verified timestamp, and active session ID are reset to null, while the cumulative attemptCount is preserved through metadata merging.
When Veriff flags a decision event with risk labels indicating duplicate identities, the webhook handler overrides the verification status to declined and asynchronously dispatches detectDuplicateIdentityFraud. This routine evaluates program enrollments against configured fraud rules and executes automated financial remediation by holding pending and processed commissions across affected accounts.
Sources: apps/web/app/api/veriff/webhook/handle-decision-event.ts:85-94, apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:17-23
The execution flow for identifying and acting on duplicate identity fraud proceeds through a series of filtering, grouping, and persistence steps:
detectDuplicateIdentityFraud() → filters riskLabels against veriffRiskLabels and extracts associated sessionIds → queries prisma.programEnrollment.findMany() for matching partners → filters enrollments via isFraudRuleEnabled() for FraudRuleType.partnerDuplicateAccount → groups enrollments by programId via partnersByProgram reducer → filters out programs with fewer than two partners → builds CreateFraudEventInput records for active enrollments → persists fraud events via createFraudEvents() → settles commission freezes via holdPendingCommissions() and holdProcessedCommissions().
Tip
detectDuplicateIdentityFraud automatically merges the current verification session ID into the extracted risk label session IDs and de-duplicates the resulting array before querying program enrollments.
Sources: apps/web/app/api/veriff/webhook/handle-decision-event.ts:89-94, apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:83-102, apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:137-144
The partner portal exposes verification components across several interactive elements, including profile sections, promotional banners, and floating reminder cards. The IdentityVerificationSection component renders within the partner profile view, handling state transitions for verification status, attempt counts, and error messaging.
Sources: apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:21-28, apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:60-91
When an action is initiated via startIdentityVerificationAction, the client executes the mutation and dynamically loads @veriff/incontext-sdk to instantiate a Veriff frame with the returned sessionUrl.
const { executeAsync, isPending } = useAction(
startIdentityVerificationAction,
{
onError: ({ error }) => {
toast.error(
parseActionError(error, "Failed to start identity verification."),
);
},
onSuccess: async ({ data }) => {
const { createVeriffFrame, MESSAGES } = await import(
"@veriff/incontext-sdk"
);
createVeriffFrame({
url: data.sessionUrl,
onEvent: (msg) => {
if (msg === MESSAGES.FINISHED) {
toast.success(
"Verification submitted. We'll update your status shortly.",
);
mutate();
}
},
});
mutate();
},
},
);Note
Maximum verification attempts are tracked via identityVerificationAttemptCount against the MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS threshold. Reaching the limit disables the start button unless the account is approved or currently pending review.
Sources: apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:79-84, apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:211-218
Partners receive prominent prompts to verify their identity via IdentityVerificationBanner and IdentityVerificationCard. The banner displays a radial-gradient background with floating shield assets, linking directly to /profile#identity-verification or allowing dismissal to the card layout by updating the promo state to card.
Sources: apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:13-38, apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:82-98
Sources: apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:13-19, apps/web/ui/partners/identity-verification/identity-verification-card.tsx:13-21
Administrators manage network partner verification via NetworkIdentityVerification, which exposes tools to generate Veriff session URLs or manually verify partners with a US LLC.
export function NetworkIdentityVerification({
partner,
}: {
partner: Pick<AdminNetworkPartner, "id" | "identityVerifiedAt">;
}) {
const [sessionUrl, setSessionUrl] = useState<string | null>(null);
const [isGeneratingSession, setIsGeneratingSession] = useState(false);
const [isVerifyingIdentity, setIsVerifyingIdentity] = useState(false);
const partnerIdRef = useRef(partner.id);
...Warning
Manual verification via handleVerifyIdentity posts to /api/admin/partners/${requestPartnerId}/verify-identity and is restricted if identityVerifiedAt is already set.
Sources: apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:72-83, apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:159-163
Sources: apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:39-43, apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:18-25, apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:49-68, apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:15-16