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:
Fraud Detection and Hold Rules provide a comprehensive risk-mitigation framework designed to safeguard partner programs against suspicious activity, duplicate accounts, and fraudulent referral traffic. By integrating real-time fraud monitoring directly into partner commission workflows, the system automatically detects policy violations—such as duplicate identities, matching customer emails, or network-level bans—and intercepts commission creation to place vulnerable earnings on hold. This proactive mechanism prevents premature payouts, recalculates balances, and manages automated hold releases or expiration schedules through orchestrated cron jobs, ensuring robust financial integrity across workspaces.
Public and workspace fraud rule configuration and validation manage how individual partner programs enforce detection mechanisms. Workspace-level settings allow authorized users to query existing rules or update rule configurations and active statuses via dedicated API endpoints. When fetching rules via GET /api/fraud/rules, the system retrieves stored configurations from the database and merges them with default overrides—such as platform settings for paid traffic detection—ensuring every rule has a valid initial state.
The PATCH /api/fraud/rules endpoint processes settings updates validated by updateFraudRuleSettingsSchema. Rules are iterated, and their configurations are persisted via database upserts, mapping enabled states to disabledAt timestamps. If a rule is disabled with resolvePendingEvents set to true, a background task executes resolveFraudGroups via Vercel's waitUntil() utility, automatically releasing held commissions with the resolution reason "Resolved automatically because the fraud rule was disabled.".
Warning
Disabling a fraud rule with resolvePendingEvents: true immediately triggers automatic group resolution and commission release in the background, which cannot be rolled back directly via the PATCH response.
The checkReferralSourceBanned rule evaluates click event contexts against a list of banned domains. It parses raw configuration using a Zod schema requiring an optional string array of domains, defaulting to an empty list.
export const checkReferralSourceBanned = defineFraudRule({
type: "referralSourceBanned",
evaluate: async ({ click }: FraudEventContext, rawConfig) => {
const parsedConfig = configSchema.safeParse(rawConfig ?? defaultConfig);
if (!parsedConfig.success) {
console.error(
`[checkReferralSourceBanned] Invalid config:`,
parsedConfig.error,
);
return {
triggered: false,
};
}
const config = parsedConfig.data;
// Normalize banned domains by extracting domains and removing www. prefix
const normalizedBannedDomains = config.domains
.map((domain) => getDomainWithoutWWW(domain))
.filter((domain): domain is string => Boolean(domain));
if (normalizedBannedDomains.length === 0 || !click) {
return {
triggered: false,
};
}
// Return early if both referer and referer_url are null/empty
if (!click.referer && !click.referer_url) {
return {
triggered: false,
};
}
// Check both referer and referer_url against banned sources
// Normalize referrers by extracting domains and removing www. prefix
const referrerCandidates = [click.referer, click.referer_url]
.filter((value): value is string => Boolean(value))
.map((referrer) => getDomainWithoutWWW(referrer))
.filter((domain): domain is string => Boolean(domain));
for (const referrer of referrerCandidates) {
for (const source of normalizedBannedDomains) {
if (minimatch(referrer, source, { nocase: true })) {
return {
triggered: true,
metadata: {
source,
},
};
}
}
}
return {
triggered: false,
};
},
});Identity and payout fraud detection mechanisms identify colluding partners by cross-referencing shared verification sessions, payment method hashes, crypto wallet addresses, and network-level bans. When overlapping identifiers or bans are detected, the system generates fraud events across active program enrollments and initiates commission holds.
The detectDuplicateIdentityFraud function accepts a Veriff session identifier and risk labels. It extracts session IDs associated with valid risk labels, appends the current session ID, deduplicates the array, and queries database program enrollments where the associated partner matches any collected Veriff session ID.
Enrollments are filtered to ensure the partnerDuplicateAccount fraud rule is enabled in the program settings. Partners are grouped by programId, and groups containing fewer than two partners are filtered out. For every eligible partner within a multi-partner group, fraud events of type partnerDuplicateAccount are created and dispatched to createFraudEvents. Finally, affected pending and processed commissions are placed on hold via settlement promises.
Note
Partners whose enrollment status is included in INACTIVE_ENROLLMENT_STATUSES or who have riskMonitoringDisabledAt populated are skipped during event generation, even if they share a Veriff session ID with active partners.
The detectDuplicatePayoutMethodFraud function evaluates either a payoutMethodHash or a cryptoWalletAddress using mutually exclusive options. It searches for program enrollments linked to partners sharing the specified payout hash or wallet address.
type DetectDuplicatePayoutMethodFraudOptions =
| { payoutMethodHash: string; cryptoWalletAddress?: never }
| { cryptoWalletAddress: string; payoutMethodHash?: never };
export async function detectDuplicatePayoutMethodFraud({
payoutMethodHash,
cryptoWalletAddress,
}: DetectDuplicatePayoutMethodFraudOptions) {
if (!payoutMethodHash && !cryptoWalletAddress) {
return;
}
let programEnrollments = await prisma.programEnrollment.findMany({
where: {
partner: {
OR: [
...(payoutMethodHash ? [{ payoutMethodHash }] : []),
...(cryptoWalletAddress ? [{ cryptoWalletAddress }] : []),
],
},
},
select: {
programId: true,
partnerId: true,
status: true,
riskMonitoringDisabledAt: true,
program: {
select: {
fraudRules: true,
},
},
},
});
...After fetching matching enrollments, the function validates that partnerDuplicateAccount is enabled, groups partners by program, excludes single-partner groups, and records fraud events containing either payoutMethodHash or cryptoWalletAddress in their metadata.
The reportNetworkLevelBan function alerts other programs when a partner is banned within a specific program. It queries all active program enrollments for the partner excluding the issuing program ID and ensuring risk monitoring is active.
export async function reportNetworkLevelBan({
partnerId,
programId,
bannedReason,
bannedAt,
}: {
partnerId: string;
programId: string;
bannedReason: PartnerBannedReason | null;
bannedAt: Date | null;
}) {
let affectedProgramEnrollments = await prisma.programEnrollment.findMany({
where: {
partnerId,
programId: {
not: programId,
},
status: {
notIn: INACTIVE_ENROLLMENT_STATUSES,
},
riskMonitoringDisabledAt: null,
},
select: {
programId: true,
partnerId: true,
program: {
select: {
fraudRules: true,
},
},
},
});
...Enrollments are filtered against the partnerCrossProgramBan fraud rule. Eligible enrollments trigger partnerCrossProgramBan fraud events with sourceProgramId set to the banning program and metadata containing bannedReason and bannedAt. Pending and processed commissions are then held for all affected groups.
Partner commission creation workflows evaluate fraud risks inline to determine whether newly recorded commissions must be placed on hold immediately upon creation. The interception mechanism operates during side-effect execution following commission creation, inspecting both real-time customer risk events and partner-level pending fraud groups.
When a commission is created, the workflow executes stepRunSideEffects(), which inspects workspace plan capabilities and verifies if risk monitoring applies to the transaction. The call chain proceeds through evaluation and status modification functions:
stepRunSideEffects() → evaluates shouldRunRiskMonitoring (checking for customer, event ID, and click event) → detectAndRecordFraudEvent() → checks canManageFraudEvents from workspace plan capabilities → prisma.fraudEventGroup.findFirst() (evaluating partner-level scope) → prisma.commission.update() (transitioning status to hold if flagged).
If risk rules trigger or pending risk groups exist, and the commission is not a clawback (commission.earnings > 0) with an explicit status input, the commission record transitions from pending to hold.
Warning
Clawback commissions (where earnings are less than or equal to zero) are never held, even if risk monitoring rules or pending partner-level fraud groups are triggered.
For network-level referrals, createNetworkReferralCommission() validates referrer program enrollment, active duration limits, and payout fee earnings before creating a referral commission record.
try {
commission = await prisma.commission.create({
data: commissionData,
});
console.log("Network referral commission created", commission);
} catch (error) {
// Don't retry on unique constraint violation – the commission already exists
// (likely a race between the dedup check and the create)
if (error.code === "P2002") {
console.log(
`Referral commission already exists for invoiceId ${commissionData.invoiceId}, skipping creation.`,
);
return null;
}
console.error(
"Error creating network referral commission",
error,
commissionData,
);
await log({
message: `[createNetworkReferralCommission] Error creating referral commission - ${error.message}`,
type: "errors",
mention: true,
});
throw error;
}Note
Unique constraint violations (P2002) during network referral commission creation are caught and ignored rather than retried, preventing duplicate insertion race conditions when an invoice ID already exists.
When partner-level fraud triggers violations such as duplicate identities, matching payout methods, or network-level bans, bulk commission hold routines execute against both pending and processed commission records.
holdPendingCommissions() processes program enrollments by mapping unique partner-program pairs and chunking them into batches of 50.
export async function holdPendingCommissions(
programEnrollments: Pick<ProgramEnrollment, "programId" | "partnerId">[],
) {
if (programEnrollments.length === 0) {
console.log("No program enrollments to hold pending commissions for");
return;
}
const uniquePairs = [
...new Map(
programEnrollments.map((e) => [`${e.programId}:${e.partnerId}`, e]),
).values(),
];
const chunks = chunk(uniquePairs, 50);
const holdEligibleWhere: Prisma.CommissionWhereInput = {
status: CommissionStatus.pending,
earnings: {
gt: 0,
},
program: {
workspace: {
plan: {
in: ["enterprise", "advanced"],
},
},
},
};Warning
Only commissions belonging to enterprise or advanced workspace plans with positive earnings (earnings > 0) are eligible for holding.
holdProcessedCommissions() queries processed commissions attached to pending payouts, updates their status to hold, sets their payoutId to null, and collects affected payout IDs into a tracking set.
// need to retally payouts to ensure the payout amount is correct
await retallyPayoutsAmount(Array.from(payoutIdsToRetallySet));Note
Because updating processed commissions detaches them from their pending payouts (payoutId: null), retallyPayoutsAmount() is invoked at the end of execution to recalculate correct payout totals.
Scheduled cron jobs and cleanup handlers manage the lifecycle of fraud groups and release held commissions back to a pending state once risks are resolved or expiration thresholds are met.
The expiration cleanup cron route (POST /api/cron/cleanup/expired-fraud-groups) executes once every day at 02:30:00 AM UTC (30 2 * * *). It queries pending fraud event groups that have exceeded their time-to-live threshold and transitions their status to expired.
const groupsToExpire = await prisma.fraudEventGroup.findMany({
where: {
status: "pending",
type: {
notIn: NON_EXPIRING_FRAUD_RULE_TYPES,
},
lastEventAt: {
lt: subDays(new Date(), FRAUD_GROUP_EXPIRY_DAYS),
},
},
select: {
id: true,
},
take: BATCH_SIZE,
});Note
FRAUD_GROUP_EXPIRY_DAYS defines the inactivity window (defaulting to 30 days based on lastEventAt), and rule types specified in NON_EXPIRING_FRAUD_RULE_TYPES are explicitly excluded from automatic expiration.
When fraud groups are resolved via resolveFraudGroups() or marked expired during cleanup, queueReleaseHoldCommissions() triggers downstream hold releases. The core release function releaseHoldCommissions() performs strict verification before altering any commission states:
blockedCustomerIds set.CommissionStatus.hold, whereas custom commissions (customerId: null) and commissions linked to unblocked customers transition from hold to pending.export async function releaseHoldCommissions({
programId,
partnerId,
resolvedGroupIds,
}: {
programId: string;
partnerId: string;
resolvedGroupIds: string[];
}) {
if (resolvedGroupIds.length === 0) {
return 0;
}
...Warning
If a partner maintains any active partner-level pending fraud group across the program, the entire commission release batch for that partner is skipped, regardless of whether specific conversion groups have been resolved.
Once commissions are successfully moved to CommissionStatus.pending, releaseHoldCommissions() executes concurrent post-update procedures via Promise.allSettled():
const results = await Promise.allSettled([
trackCommissionStatusUpdate({
workspaceId: program.workspaceId,
programId,
commissions: releasedCommissions,
newStatus: CommissionStatus.pending,
}),
syncTotalCommissions({
partnerId,
programId,
}),
triggerAggregateDueCommissionsCronJob(programId),
releasedEarnings > 0 &&
executeWorkflows({
event: "commissionRecorded",
identity: {
workspaceId: program.workspaceId,
programId,
partnerId,
},
metrics: {
current: {
commissions: releasedEarnings,
},
},
}),
]);Additionally, program downgrades or manual overrides can execute releaseAllHoldCommissions(), which bypasses customer-level filtering and releases all hold commissions belonging to a specific program.
The Risk Center interface and its associated audit operations provide workspace administrators with tooling to review flagged fraud event groups, examine associated commissions on hold, and execute backfill migrations for retroactively applying hold rules. Access to fraud risk layouts is governed by workspace plan capabilities via getPlanCapabilities(), which evaluates canManageFraudEvents and displays a RiskCenterUpsell component when capabilities are unavailable.
The RiskEventsTable component retrieves pending fraud groups using useFraudGroups and integrates filters, batch action modals, and review sheets. When investigating a specific risk group, the AssociatedCommissionsTable queries held commissions through the /api/commissions endpoint with a designated fraudEventGroupId and status: "hold".
const query = {
workspaceId: workspaceId!,
status: "hold",
fraudEventGroupId: fraudGroup.id,
partnerId: fraudGroup.partner.id,
};The table configures columns for creation date, associated customer row items, commission types via CommissionTypeBadge, currency-formatted amounts, and action menus, with row auxiliary click handlers linking directly to individual commission detail views.
Note
Associated commission queries utilize keepPreviousData: true alongside SWR fetchers to maintain UI stability during pagination across hold records.
When hold-on-fraud rules are introduced or updated, backfill scripts such as backfill-hold-pending-commissions.ts and backfill-hold-processed-commissions.ts scan pending FraudEventGroup records in batches of 50 and transition matching eligible commissions to CommissionStatus.hold.
Warning
Because updateMany re-checks eligibility, a commission's status can change between findMany and updateMany (e.g., if a payout is updated). The scripts explicitly re-verify held IDs before tracking activity logs or syncing partner totals.