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
Top comments (6)
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?
Great question, and spot on about network-level rejection happening at submission rather than staging.
In the article snippet we threw a hard error to keep the helper self-contained, but in production we treat it as a non-mutating warning rather than an exception. When an operator's existing notes already reach the 18,000-unit ceiling on their own, the webhook handler skips the
POST /v1/disputes/:idcall altogether, leaves the Stripe draft untouched, and flags the dispute in the review drawer so the team can decide what to trim before re-staging.the review-drawer flag is the right place for that decision, since a human reads the AE's original notes there anyway. one thing that would make the re-stage a one-pass fix instead of a trim-and-retry loop: if the warning surfaced the unit count per delimiter segment rather than just the total, the operator can see which block pushed them over and cut exactly that one. does your handler carry segment-level counts in the flag payload?
Yes — we just shipped per-segment UTF-16 unit attribution (
segmentBreakdown) inrevclamp-mcp@1.20.10and in the browser linter for exactly that reason, so an operator never has to guess which section pushed the mergedaccess_activity_logover the 18,000-unit ceiling.Whenever a draft is evaluated or flagged, the payload now carries
segmentBreakdownalongside the overall length check:prefixUtf16CodeUnits: existing AE/operator notes before[REVCLAMP_USAGE_EVIDENCE_START]preSeparatorUtf16CodeUnits&postSeparatorUtf16CodeUnits:\n\nboundary separators (0or2units each)revClampBlockUtf16CodeUnits: the delimited RevClamp usage block (including the82-unit start/end markers + newlines)suffixUtf16CodeUnits: existing AE/operator notes after[REVCLAMP_USAGE_EVIDENCE_END]wrapperOverheadUtf16CodeUnits: exact delimiter + separator overhead (82–86UTF-16 code units)totalMergedUtf16CodeUnits,remainingSafeBudgetUtf16CodeUnits, andoverBudgetByUtf16CodeUnitsThat lets the review drawer (or an automated triage step) point directly to the exact segment and exact number of UTF-16 code units to trim in a single pass. You can inspect the live breakdown via CLI (
npx -y revclamp-mcp@1.20.10 --lint-evidence) or in the browser linter at revclamp.com/tools/stripe-dispute-....per-segment attribution closes the loop nicely. a total-only count leaves the operator bisecting the merged blob by eye when a flag fires; knowing prefix, separators, block, and suffix independently means the drawer can tell 'the block grew' from 'the AE notes ballooned', which are very different conversations with different owners. the remainingSafeBudget and overBudgetBy fields also make an automated triage step almost trivial to bolt on. does the browser linter share the same payload shape, or does it compute the breakdown on its own port?
They share the exact same payload shape. Both the browser linter and the staging handler return
segmentBreakdownwith the identical 9 fields (prefixUtf16CodeUnits,preSeparatorUtf16CodeUnits,revClampBlockUtf16CodeUnits,postSeparatorUtf16CodeUnits,suffixUtf16CodeUnits,wrapperOverheadUtf16CodeUnits,totalMergedUtf16CodeUnits,remainingSafeBudgetUtf16CodeUnits, andoverBudgetByUtf16CodeUnits) alongside the same top-level budget fields (merchantNotesUtf16CodeUnits,fixedWrapperUtf16CodeUnits,merchantLogAtCapacity, andstagingActionWhenAtCapacity).Under the hood, the browser linter (
POST /api/tools/dispute-evidence-lint), the remote Streamable HTTP MCP server (POST /api/mcp), and the local stdio MCP CLI (local_engine.mjs) are verified against a 14-row × 19-key cross-surface parity test matrix on every build. That way, any automated triage rule or review-drawer parser written against one surface works identically across all three without adapter glue.