When a B2B SaaS or AI/API merchant receives a charge.dispute.created webhook for Visa Reason Code 10.4 (fraudulent), staging Visa Compelling Evidence 3.0 (CE 3.0) historical charge pairs alongside 90-day product usage telemetry inside evidence[access_activity_log] is the highest-leverage defense available.

Yet three subtle engineering mistakes repeatedly ruin automated dispute webhooks on Stripe:

  1. Omitting submit on POST /v1/disputes/:id defaults to submit: true: In Stripe's Dispute Update API, the submit parameter defaults to true when omitted. A naive stripe.disputes.update(disputeId, { evidence: { access_activity_log } }) call immediately locks and submits the dispute to the card network before finance can attach signed master service agreement contracts or invoice PDFs.
  2. Clobbering human account executive and support notes: Writing evidence[access_activity_log] without reading the existing dispute object erases manual context already drafted in the Stripe Dashboard.
  3. Measuring character budgets in UTF-8 bytes (Buffer.byteLength) instead of UTF-16 code units (.length): Multi-byte characters and astral-plane surrogate pairs (U+10000..U+10FFFF) cause naive slicing to either overflow character caps or split a surrogate pair in half (0xD800..0xDBFF).

1. Visa 10.4 (CE 3.0) Qualification & Tier 1 Usage Fallback

Qualifying for Visa CE 3.0 liability shift requires at least 2 prior undisputed, unrefunded charges on the same payment method settled 120 to 365 days before the disputed transaction that share at least 2 core identifiers (ip_address, device_id, user_account_id, customer_id, shipping_address) across both prior charges and the disputed charge—and at least one matched identifier must be ip_address or device_id. When fewer than 2 historical charges qualify for Visa CE 3.0, the engine automatically falls back to staging Tier 1 verified 90-day first-party product usage telemetry inside the same delimited 18,000 UTF-16 budget.

2. Delimiter-Preserving Merge (buildNonDestructiveDisputeDraft) & the 18,000 UTF-16 Safety Ceiling

To leave a 2,000-unit safety buffer below Stripe's 20,000-character limit, our buildNonDestructiveDisputeDraft engine enforces MAX_ACCESS_ACTIVITY_LOG_CHARS = 18_000 measured strictly in UTF-16 code units (str.length), fails closed if existing merchant notes already exceed the budget, and splices only inside deterministic delimiter markers (--- BEGIN REVCLAMP CE3 EVIDENCE --- / --- END REVCLAMP CE3 EVIDENCE ---) (simplified reference excerpt of buildNonDestructiveDisputeDraft below):

const START = '--- BEGIN REVCLAMP CE3 EVIDENCE ---'; const END = '--- END REVCLAMP CE3 EVIDENCE ---'; const MAX_ACCESS_ACTIVITY_LOG_CHARS = 18_000; // 2,000-unit buffer under 20k limit // Simplified reference excerpt of buildNonDestructiveDisputeDraft: export function mergeAroundDelimiterBlocks(existingLog = '', ce3BlockText = '', { maxUtf16Units = MAX_ACCESS_ACTIVITY_LOG_CHARS } = {}) { const sIdx = existingLog.indexOf(START); const eIdx = existingLog.indexOf(END); const hasValidMarkers = sIdx !== -1 && eIdx !== -1 && eIdx > sIdx; const rawHead = hasValidMarkers ? existingLog.slice(0, sIdx).trimEnd() : existingLog.trimEnd(); const rawTail = hasValidMarkers ? existingLog.slice(eIdx + END.length).trimStart() : ''; const head = rawHead ? `${rawHead}\n\n` : ''; const tail = rawTail ? `\n\n${rawTail}` : ''; const buildCandidate = (body) => `${head}${START}\n${body ? body + '\n' : ''}${END}${tail}`; if (buildCandidate('').length > maxUtf16Units) { throw new Error('EXISTING_NOTES_EXCEED_18K_UTF16_CAP'); } const lines = Array.isArray(ce3BlockText) ? ce3BlockText : String(ce3BlockText || '').split('\n'); const keptLines = []; for (const line of lines) { const candidateBody = [...keptLines, line].join('\n'); if (buildCandidate(candidateBody).length > maxUtf16Units) break; // Whole-line UTF-16 budget keptLines.push(line); } return buildCandidate(keptLines.join('\n')); } const mergedLog = mergeAroundDelimiterBlocks( existingDispute?.evidence?.access_activity_log ?? '', ce3BlockText, { maxUtf16Units: 18_000 } ); await stripe.disputes.update(disputeId, { evidence: { access_activity_log: mergedLog }, submit: false, // Hard-locked: omitting submit defaults to submit: true on Stripe! });