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:
- Omitting
submitonPOST /v1/disputes/:iddefaults tosubmit: true: In Stripe's Dispute Update API, thesubmitparameter defaults totruewhen omitted. A naivestripe.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. - 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. - 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! }); - Engineering deep-dive: Visa CE 3.0 & 18,000 UTF-16 Truncation Pitfalls
- Interactive sandbox: Free 18,000 UTF-16 Dispute Evidence Linter


