Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 098 / 100

Handling disputes evidence: patterns, pitfalls and fixes

Win more disputes.

Expona Admin10 min read

Introduction

By Day 98 of payment integration work, you've handled authorizations, captures, and refunds—but disputes are where payment systems truly test your operational discipline. Handling disputes evidence: patterns, pitfalls and fixes is the difference between recovering revenue and watching chargebacks erode your margins month after month.

When a customer disputes a charge, Stripe gives you a narrow window—typically 7 to 10 days—to submit compelling evidence. Miss the deadline or submit weak documentation, and you lose by default. Get it right, and you win disputes that could otherwise be classified as losses.

Delivery confirmations and logs

The strongest defense in any dispute is proof that the customer received what they ordered. Delivery confirmations are gold. Whether you're selling SaaS access, physical goods, or services, you need a reliable trail showing that delivery happened.

For e-commerce, that's a carrier-signed proof of delivery with signature confirmation or geolocation. For digital products, it's login logs, download timestamps, or email delivery confirmations. For SaaS, it's API logs and audit trails showing account creation and feature access.

Many teams gather this evidence manually when disputes arrive—but by then, logs may have rotated or been deleted. Smart teams build evidence collection into their backend from day one.

Logging delivery events

Create a dedicated events table or message queue that captures every material action on a customer's account:

typescript
// Track delivery events in a relational database
async function logDeliveryEvent(
  customerId: string,
  orderId: string,
  eventType: 'order_created' | 'product_delivered' | 'access_granted' | 'download_completed',
  metadata: Record
) {
  await db.insert(delivery_events).values({
    customer_id: customerId,
    order_id: orderId,
    event_type: eventType,
    event_timestamp: new Date(),
    user_agent: metadata.userAgent,
    ip_address: metadata.ipAddress,
    metadata: JSON.stringify(metadata),
  });
}

// Usage after successful fulfillment
await logDeliveryEvent(
  customerId,
  orderId,
  'product_delivered',
  {
    carrier: 'FedEx',
    tracking_number: 'XXXX',
    signed_by: 'J. Smith',
    delivery_time: new Date(),
    userAgent: req.headers['user-agent'],
    ipAddress: req.ip,
  }
);

When a dispute lands in your queue, query this table and export timestamped events immediately. Include IP addresses and user agents—they prove the customer was actually accessing their account from locations and devices tied to their order.

Digital product access logs

For SaaS or digital downloads, Stripe wants to see that the customer actively used what they purchased. Database query logs can feel scattered; a structured audit log is cleaner:

typescript
// Audit log for SaaS access
async function logAccountAccess(
  customerId: string,
  action: 'login' | 'feature_used' | 'content_downloaded',
  details: any
) {
  const logEntry = {
    timestamp: new Date(),
    customer_id: customerId,
    action,
    session_id: crypto.randomUUID(),
    ip_address: details.ipAddress,
    details: JSON.stringify(details),
  };
  
  // Write to audit log (PostgreSQL, MongoDB, etc.)
  await auditLog.create(logEntry);
  
  // Optional: also write to S3 for immutable record
  await s3.putObject({
    Bucket: 'audit-logs',
    Key: `${customerId}/${new Date().toISOString()}.json`,
    Body: JSON.stringify(logEntry),
  });
}

When you export this for dispute evidence, Stripe sees a clear timeline: customer logged in on order date, used features on specific dates, downloaded resources. Disputes claiming "I never received access" collapse under that weight.

Customer communication records

Disputes often hinge on whether the customer was informed. If you have email confirmations, order status updates, or support ticket exchanges, you hold the narrative. The absence of communication makes you look negligent; its presence proves you operated in good faith.

Email trail requirements

Save every transactional email: order confirmation, shipment notification, delivery updates, and customer service replies. Many teams delete old emails to save space, then panic when a dispute arrives. Store them permanently.

For digital products, send a confirmation email immediately after purchase that includes:

  • Order ID and timestamp
  • Product name and quantity
  • Price paid and currency
  • Account login link or download link
  • Your support contact information
typescript
// Send and archive order confirmation
async function sendOrderConfirmation(order: Order, customer: Customer) {
  const emailContent = buildOrderEmailHTML(order, customer);
  
  // Send via email provider
  const emailResult = await sendEmail({
    to: customer.email,
    subject: `Order Confirmation #${order.id}`,
    html: emailContent,
  });
  
  // Archive for disputes
  await db.insert(email_archives).values({
    order_id: order.id,
    customer_id: customer.id,
    recipient: customer.email,
    sent_at: new Date(),
    subject: `Order Confirmation #${order.id}`,
    body_html: emailContent,
    provider_message_id: emailResult.id,
    bounce_status: 'sent', // track if email bounced later
  });
}

In your dispute response, paste the confirmation email verbatim. Show that the customer received the product details, access credentials, or shipment tracking immediately. That email is often the best single piece of evidence you have.

Support conversations

If a customer contacted support before or after the charge, that conversation is critical context. A customer asking "where's my order?" three days after purchase and being given a tracking number weakens a chargeback claim. A customer saying "I approve this charge" in a support ticket is near-definitive proof.

Export these conversations in full, including timestamps and agent notes:

typescript
// Fetch support conversation for dispute
async function getCustomerSupportHistory(customerId: string, orderId: string) {
  const tickets = await db
    .select()
    .from(support_tickets)
    .where(
      and(
        eq(support_tickets.customer_id, customerId),
        gte(support_tickets.created_at, order.created_at),
        lte(support_tickets.created_at, new Date(order.created_at.getTime() + 60 * 86400000)) // 60 days after
      )
    )
    .orderBy(asc(support_tickets.created_at));

  return tickets.map(ticket => ({
    id: ticket.id,
    opened_at: ticket.created_at,
    subject: ticket.subject,
    messages: ticket.messages.map(m => ({
      timestamp: m.created_at,
      from: m.author_type, // 'customer' or 'agent'
      body: m.body,
    })),
    resolved_at: ticket.resolved_at,
  }));
}

Clear refund policies

A customer cannot dispute a charge they explicitly understood and agreed to. Your refund policy is your legal shield—but only if customers see it before purchase.

Placement and acceptance

Display your refund policy:

  • On the product page before checkout
  • In the checkout flow, explicitly, with a checkbox if possible ("I agree to the refund policy")
  • In the order confirmation email
  • On your support/FAQs page

When disputes arise, Stripe asks: "Did the customer see and accept your terms?" If your policy is buried in footer text with 6-point font, Stripe won't treat it as binding. Make it visible and logged.

typescript
// Log policy acceptance at checkout
async function recordPolicyAcceptance(
  customerId: string,
  orderId: string,
  policyVersion: string,
  acceptanceTimestamp: Date
) {
  await db.insert(policy_acceptances).values({
    customer_id: customerId,
    order_id: orderId,
    policy_version: policyVersion,
    policy_type: 'refund_policy',
    accepted_at: acceptanceTimestamp,
    ip_address: getClientIp(),
    user_agent: getUserAgent(),
  });
}

// On checkout submit
if (!policyCheckbox.checked) {
  throw new Error('Customer must accept refund policy');
}
await recordPolicyAcceptance(
  customerId,
  orderId,
  'v2.1', // track which version they saw
  new Date()
);

Policy content for disputes

Your policy should address dispute-prone scenarios:

  • Digital products: "All sales final. No refunds after download or access granted." Include a trial or preview link so customers can verify before buying.
  • Subscriptions: "Refunds available within 14 days of charge. After 14 days, only future billing can be adjusted." (This sets dispute expectations.)
  • Custom/personalized items: "Non-returnable due to customization. Defects or errors refunded within 30 days of discovery."
  • Shipping: "Free shipping included. Shipping cost is non-refundable if order is cancelled after dispatch."

When you submit dispute evidence, quote your policy word-for-word. Show the customer accepted it. That shifts the burden back to the customer to prove the policy was misleading or the product didn't match its description.

Submit before the deadline

Stripe's dispute window is fixed and unforgiving. Most disputes must be answered within 7 days of creation; some card networks allow 10. Missing that deadline results in an automatic loss, regardless of evidence quality.

Notification and triage

The moment Stripe notifies you of a dispute, log it and route it to your team:

typescript
// Webhook handler for dispute creation
async function handleDisputeCreated(event: Stripe.Event) {
  const dispute = event.data.object as Stripe.Dispute;
  
  // Log immediately
  await db.insert(disputes).values({
    stripe_dispute_id: dispute.id,
    charge_id: dispute.charge as string,
    customer_id: dispute.metadata.customer_id,
    amount_cents: dispute.amount,
    reason_code: dispute.reason,
    evidence_due_by: dispute.evidence_details.due_by,
    created_at: new Date(dispute.created * 1000),
    status: 'new',
  });
  
  // Alert via Slack/email
  await notifyTeam({
    channel: 'disputes',
    message: `Dispute created: $${dispute.amount / 100} on charge ${dispute.charge}. Due: ${new Date(dispute.evidence_details.due_by * 1000).toISOString()}`,
    priority: 'high',
  });
}

Set a calendar reminder 24 hours before the deadline. Don't wait until the last hour to gather evidence.

Evidence submission workflow

Build a checklist and follow it systematically:

  1. Retrieve the charge details from Stripe and your database
  2. Pull delivery confirmations, logs, and timestamps
  3. Export customer communication records
  4. Screenshot your refund policy as it appeared to the customer
  5. Write a 2-3 sentence narrative explaining why the dispute is invalid
  6. Compile all files into a single PDF or document
  7. Submit via Stripe dashboard or API 24 hours before deadline
  8. Log the submission with timestamp and evidence contents
typescript
// Automated evidence submission helper
async function submitDisputeEvidence(disputeId: string, evidence: DisputeEvidence) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  
  const submission = await stripe.disputes.update(disputeId, {
    evidence: {
      access_activity_log: evidence.accessLog,
      cancellation_policy: evidence.refundPolicy,
      customer_communication: evidence.emailChain,
      customer_email_address: evidence.customerEmail,
      customer_name: evidence.customerName,
      product_description: evidence.productDescription,
      receipt: evidence.receipt,
      refund_policy: evidence.refundPolicy,
      shipping_address: evidence.shippingAddress,
      shipping_carrier: evidence.shippingCarrier,
      shipping_date: evidence.shippingDate,
      shipping_documentation: evidence.trackingNumber,
      shipping_tracking_number: evidence.trackingNumber,
      uncategorized_file: evidence.otherProofs,
      uncategorized_text: evidence.additionalNotes,
    },
  });
  
  // Log submission
  await db.insert(dispute_submissions).values({
    dispute_id: disputeId,
    submitted_at: new Date(),
    evidence_types: Object.keys(evidence).filter(k => evidence[k]),
    stripe_response: submission.status,
  });
  
  return submission;
}

Stripe accepts evidence as file uploads or text strings. For logs and policies, paste the full text directly into the appropriate fields. For receipts and shipping documentation, attach PDF or image files.

Common mistakes

  • Submitting a narrative without proof: Telling Stripe "the customer definitely received the product" without timestamps, logs, or tracking numbers carries no weight. Every claim must be backed by data.
  • Waiting until the last day: If you discover missing evidence on day 6, you have no time to investigate. Gather evidence on day 1 of the dispute.
  • Ignoring digital product disputes: Many teams assume SaaS disputes are unwinnable and give up. They're not—login logs and feature usage records are powerful evidence.
  • Not tracking policy acceptance: If you can't prove the customer saw and agreed to your refund policy, Stripe assumes you never showed it. Log every acceptance.
  • Mixing evidence types: Submit organized, categorized evidence. Stripe reviewers are reading dozens of disputes daily. A clear, labeled submission wins faster than a folder of random documents.
  • Assuming Stripe will contact you: After you submit, Stripe reviews and decides. You generally don't get to rebut or clarify. Submit complete, defensible evidence the first time.

Key takeaways

  • Build evidence collection into your backend on day one: log delivery events, access logs, and policy acceptances from the start, not when disputes arrive.
  • Archive all transactional emails and support conversations permanently; they're your best evidence that the customer was informed.
  • Display your refund policy prominently at checkout and log customer acceptance with IP/timestamp to prove they understood the terms.
  • Set dispute deadline alerts 24 hours in advance; missing the window is an automatic loss regardless of how strong your evidence is.
  • Submit evidence organized by type (logs, communication, policy) with a brief narrative; clean, structured submissions persuade Stripe reviewers faster than scattered documents.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles