Non-Destructive Visa CE 3.0 Dispute Evidence Staging on Stripe: Delimiter Merging and the 18,000 UTF-16 Safety Ceiling
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).
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.
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
Top comments (1)
The clobbering problem is underrated. Anyone who has automated dispute evidence has wiped out an AE's handwritten context at least once, and the delimiter-merge approach is the cleanest fix I have seen described because it survives the case where the block markers are absent entirely rather than assuming they exist. The UTF-16 point deserves emphasis too: evidence fields get rejected for length by the network, not by Stripe, so a byte-counted trim that looks fine locally sails through staging and fails at submission, which is the worst possible place to discover it. One question: when the existing log already exceeds the 18k cap on its own, do you surface that as a hard error the way the code here does, or fall back to a warning and let the operator decide what to cut?
Comments
No comments yet. Start the discussion.