Non-Destructive Visa CE 3.0 Dispute Evidence Staging on Stripe: Delimiter Merging and the 18,000 UTF-16 Safety Ceiling
DEV Community

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 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.
  • 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?

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.