DEV Community

Cover image for Non-Destructive Visa CE 3.0 Dispute Evidence Staging on Stripe: Delimiter Merging and the 18,000 UTF-16 Safety Ceiling
RevClamp Enterprise
RevClamp Enterprise

Posted on Originally published at revclamp.com

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:

  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!
});
Enter fullscreen mode Exit fullscreen mode

Top comments (6)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

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?

Collapse
 
revclamp profile image
RevClamp Enterprise •

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/:id call 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.

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

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?

Thread Thread
 
revclamp profile image
RevClamp Enterprise •

Yes — we just shipped per-segment UTF-16 unit attribution (segmentBreakdown) in revclamp-mcp@1.20.10 and in the browser linter for exactly that reason, so an operator never has to guess which section pushed the merged access_activity_log over the 18,000-unit ceiling.

Whenever a draft is evaluated or flagged, the payload now carries segmentBreakdown alongside the overall length check:

  • prefixUtf16CodeUnits: existing AE/operator notes before [REVCLAMP_USAGE_EVIDENCE_START]
  • preSeparatorUtf16CodeUnits & postSeparatorUtf16CodeUnits: \n\n boundary separators (0 or 2 units each)
  • revClampBlockUtf16CodeUnits: the delimited RevClamp usage block (including the 82-unit start/end markers + newlines)
  • suffixUtf16CodeUnits: existing AE/operator notes after [REVCLAMP_USAGE_EVIDENCE_END]
  • wrapperOverheadUtf16CodeUnits: exact delimiter + separator overhead (82–86 UTF-16 code units)
  • totalMergedUtf16CodeUnits, remainingSafeBudgetUtf16CodeUnits, and overBudgetByUtf16CodeUnits

That 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-....

Thread Thread
 
fintechrailsdigest profile image
Fintech Rails Digest •

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?

Thread Thread
 
revclamp profile image
RevClamp Enterprise •

They share the exact same payload shape. Both the browser linter and the staging handler return segmentBreakdown with the identical 9 fields (prefixUtf16CodeUnits, preSeparatorUtf16CodeUnits, revClampBlockUtf16CodeUnits, postSeparatorUtf16CodeUnits, suffixUtf16CodeUnits, wrapperOverheadUtf16CodeUnits, totalMergedUtf16CodeUnits, remainingSafeBudgetUtf16CodeUnits, and overBudgetByUtf16CodeUnits) alongside the same top-level budget fields (merchantNotesUtf16CodeUnits, fixedWrapperUtf16CodeUnits, merchantLogAtCapacity, and stagingActionWhenAtCapacity).

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.