
Winning Chargebacks in Mexico: Build the Reason-to-Evidence Pipeline for Conekta and Mercado Pago
A contracargo is a card dispute where the issuing bank pulls the funds; to keep the money you submit evidence rebutting the specific reason, before a deadline, per rail. Most losses are missing evidence you could have logged at checkout. Build a reason-to-evidence pipeline: log inputs, assemble the packet on the webhook, submit via the API. This is an engineer's pattern, not legal advice.
The bug isn’t the dispute — it’s the evidence you never logged
A contracargo (chargeback) is a card dispute: a cardholder tells their issuing bank “no reconozco el cargo” or “never got it,” the bank pulls the funds back out of your account, and to keep the money you must submit evidence that rebuts the specific dispute reason, before the deadline, following the card-brand and issuer rules. Miss any of those three and you lose by default.
Here’s the part the gateway help-docs gloss over: by the time the dispute webhook fires, the fight is mostly already decided. The dispute lands weeks after the sale. If you didn’t capture the 3DS authentication result, the IP and device, the tracking number, the delivery confirmation — you have nothing to upload. The bug isn’t the dispute. The bug is the evidence you never logged.
So this is an engineering problem, not a customer-service one. The job is a pipeline: map each dispute reason to the evidence that rebuts it, log those inputs at checkout and fulfillment, and on the dispute webhook assemble the reason-matched packet and submit it programmatically per rail. I build this in production for Mexican clients on Mercado Pago and Conekta, and I’ll walk the whole thing.
One honest framing up front, because it matters: this is an engineer’s pattern, not legal or compliance advice. The issuer decides the outcome, not you. Resolution takes weeks to months. You will not win them all. What you can do is stop losing the ones you should have won — the ones where the only thing missing was a field you could have stored.
What evidence actually rebuts each reason?
Before any code, the conceptual core: the evidence matrix. A chargeback isn’t generic — it carries a reason, and each reason is rebutted by different evidence. Uploading a tracking number against a “no reconozco el cargo” fraud claim does nothing. Match the reason or you lose.
The three reasons you’ll see most in Mexico:
- Fraud / “no reconozco el cargo” — the cardholder claims they didn’t authorize it. Rebut with the 3DS authentication result (if the issuer authenticated the cardholder, liability often shifts), AVS result, device fingerprint and IP, delivery proof, and prior purchase history with the same card/account.
- Producto no recibido (item not received) — rebut with the tracking number, the delivery confirmation (carrier signature/timestamp), and the customer comms thread.
- Producto no como se describe (not as described) — rebut with the listing/description the customer saw, the accepted terms and conditions, your refund policy, and the comms showing you offered a resolution.
The whole point: every one of these is data you should already be logging at checkout and fulfillment. The packet is assembled, not gathered after the fact. If you’re scrambling to find it when the webhook fires, you’ve already lost the leak — go fix step one.
Log the evidence at checkout and fulfillment (before the dispute lands)
You cannot submit what you didn’t capture. The dispute arrives weeks after the sale, so the only way to have the evidence is to persist it now, keyed by the payment/charge id so you can join it back later.
At checkout, log the 3DS2 result, AVS, IP and device fingerprint, the payment/charge id, and a pointer to the customer’s history. At fulfillment, log the tracking number, delivery confirmation, timestamps, and the comms thread. Here’s an illustrative schema — adapt the fields to your gateway’s actual response objects:
-- Illustrative evidence log. Key everything by the gateway's payment/charge id
-- so a dispute webhook weeks later can join straight to it.
CREATE TABLE dispute_evidence (
id BIGSERIAL PRIMARY KEY,
payment_id TEXT NOT NULL, -- MP payment id / Conekta charge id
-- checkout-time signals
three_ds_result TEXT, -- e.g. "authenticated" / "attempted" / "failed"
avs_result TEXT,
ip INET,
device_id TEXT,
customer_id TEXT,
-- fulfillment-time signals
tracking_number TEXT,
delivery_proof_url TEXT, -- carrier confirmation, signature, photo
comms_url TEXT, -- exported thread (PDF/PNG)
terms_url TEXT, -- accepted T&Cs / refund policy snapshot
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (payment_id)
);
The discipline is boring and it’s the whole game: write the row at checkout, update it at fulfillment, never delete it. For more on capturing 3DS and webhook state on Conekta specifically, see my Conekta integration guide, and for the Mercado Pago side, how to integrate Mercado Pago.
On the webhook, build the reason-specific packet
Step two: handle the dispute webhook on each rail and assemble the reason-matched packet from your log.
On Mercado Pago, you only submit if the chargeback’s documentation_required field is true and date_documentation_deadline is a future date. If documentation isn’t required, there’s nothing to do; if the deadline has passed, you’ve missed it. On Conekta, you react to the charge.chargeback lifecycle events — created, updated, under_review, lost, won (confirm the exact sub-event names against Conekta’s current docs) — and the payload carries a files array and an evidence_due_by deadline (a Unix timestamp).
The handler looks up the reason, pulls the matching evidence from your log, and builds the packet. Idempotent, because webhooks retry:
# Illustrative dispute-webhook handler. Branches on reason -> evidence matrix,
# then submits per rail. Confirm field names + rules against each provider's
# CURRENT docs before shipping.
EVIDENCE_BY_REASON = {
"fraud": ["three_ds_result", "avs_result", "ip", "device_id",
"delivery_proof_url", "customer_id"],
"product_not_received": ["tracking_number", "delivery_proof_url", "comms_url"],
"product_not_as_described": ["terms_url", "comms_url", "delivery_proof_url"],
}
def handle_dispute(event, db):
dispute_id = event["data"]["id"]
if db.already_handled(dispute_id): # idempotency guard — webhooks retry
return 200
reason = normalize_reason(event["data"]["reason"])
evidence = db.get_evidence(event["data"]["payment_id"])
fields = EVIDENCE_BY_REASON.get(reason, EVIDENCE_BY_REASON["fraud"])
packet = build_packet(evidence, fields) # gathers files + notes for this reason
db.mark_handled(dispute_id)
return packet # then dispatch to submit_to_mp /
# submit_conekta_evidence (next sections)
Submitting to Mercado Pago: the chargebacks API rules
Mercado Pago has a dedicated chargebacks API, and the rules are exact. You upload your packet to the chargeback’s documentation endpoint (per MP’s docs, POST /v1/chargebacks/{id}/documentation). The file rules:
- Files must be .jpg, .png, or .pdf.
- Maximum 10 MB (confirm whether MP applies this per file or to the total upload in their current docs).
- A successful upload returns HTTP 200 and moves
documentation_statusfromnot_suppliedtoreview_pending.
You only submit when documentation_required is true and date_documentation_deadline is in the future. Then you wait — and this is the part clients hate: resolution can take up to 6 months depending on the card brand, and the disputed amount is held in your account that entire time. While the dispute runs the payment is flagged as disputed (confirm the exact status value in MP’s current docs), and the final outcome surfaces in coverage_applied (true = funds returned, false = deducted, null = pending).
# Illustrative — Mercado Pago chargebacks documentation upload.
# Files: .jpg/.png/.pdf. MP limits the upload to 10 MB — confirm in their current
# docs whether that's per file or total. Expect HTTP 200 -> review_pending.
import requests
def submit_to_mp(chargeback, packet, token):
if not chargeback["documentation_required"]:
return # nothing to submit
if chargeback["date_documentation_deadline"] <= now_iso():
log.warning("MP deadline passed, cannot submit %s", chargeback["id"])
return
total = 0
for f in packet.files: # enforce the rules BEFORE the call
assert f.ext in {"jpg", "png", "pdf"}, "MP: bad file type"
total += f.size_bytes
assert total <= 10 * 1024 * 1024, "MP: upload over 10 MB"
r = requests.post(
f"https://api.mercadopago.com/v1/chargebacks/{chargeback['id']}/documentation",
headers={"Authorization": f"Bearer {token}"},
files=[("file", (f.name, f.bytes, f.mime)) for f in packet.files],
)
r.raise_for_status() # 200 == accepted -> review_pending
return r.json()
The full reference is Mercado Pago — Gestión de contracargos. Treat these specifics as time-sensitive: as of 2026, confirm the current rules — including the exact endpoint path and the documentation_status enum — in their docs.
Submitting to Conekta: the charge.chargeback event
Conekta mirrors the same shape with its own events. It fires the charge.chargeback lifecycle — charge.chargeback.created, .updated, .under_review, .lost, .won — and lets you submit notes plus files to dispute and track status. The payload exposes a files array (each file object carries id, file_name, url, created_at) and an evidence_due_by deadline as a Unix timestamp.
Two things to verify, not hardcode: Conekta’s docs do not publish the exact maximum file size, and the precise sub-event names can move. So confirm the file-size limit, the submission window, and the exact event names against Conekta’s current docs before you ship — don’t hardcode a number or an enum you can’t cite.
# Illustrative — Conekta charge.chargeback path. Same pattern as MP:
# match reason -> assemble packet -> submit before evidence_due_by -> track via events.
def handle_conekta_event(event, db):
kind = event["type"] # charge.chargeback.created | updated | under_review | lost | won
cb = event["data"]["object"]
if kind == "charge.chargeback.created":
deadline = cb["evidence_due_by"] # Unix timestamp
packet = build_packet_for(cb) # reason-matched evidence
# CONFIRM Conekta's current max file size + window + event names before uploading.
submit_conekta_evidence(cb["id"], packet, deadline)
elif kind in ("charge.chargeback.won", "charge.chargeback.lost"):
db.record_outcome(cb["id"], won=(kind.endswith("won")))
Reference: Conekta — charge.chargeback.
Mercado Pago
- Dedicated chargebacks API
- Files .jpg/.png/.pdf, 10 MB limit (per file or total — confirm)
- Submit only if documentation_required + future date_documentation_deadline
- Upload 200 -> documentation_status review_pending
- Resolution up to 6 months by card brand
- Disputed amount held until resolved
Conekta
- charge.chargeback lifecycle events
- Events created/updated/under_review/lost/won — confirm exact names
- Payload has files array + evidence_due_by (Unix ts)
- Submit notes + files to dispute
- File-size limit not stated — confirm in current docs
- Track outcome via won / lost events
Track status, reconcile the held amount, and measure your win rate
Submission is not the end — it’s the middle. This is a pipeline, so close the loop.
Track the status: on MP, documentation_status moves review_pending -> resolved; on Conekta, you wait for the charge.chargeback.won or .lost event. Reconcile the held amount once it resolves — remember the money is locked the whole time, so your books need to reflect held-but-not-lost vs. actually-lost, not treat the disputed sale as gone.
Then the step almost everyone skips: measure your win rate per reason and per card brand. That breakdown is where the money is. If you lose 80% of “producto no recibido” disputes, the diagnosis is almost always one missing input — you weren’t logging delivery confirmation. Feed that straight back into step one and start logging it. Most losses point to a specific, fixable hole in your evidence log.
- Log at checkout + fulfillment3DS/AVS/IP/device; tracking, delivery, comms — keyed by payment id
- On the dispute, assemble the reason packetMatch reason -> pull matching evidence from the log
- Submit before the deadlineMP: .jpg/.png/.pdf, 10 MB; Conekta: confirm limits
- Track status to resolvedreview_pending -> resolved / won|lost
- Measure win rate per reason + brandFind the leak, log what you were missing
FAQ
Will this guarantee I win? No. The issuing bank decides, resolution takes weeks to months, and you won’t win them all. This recovers money you’d otherwise forfeit by default — it doesn’t override the issuer.
Is this legal advice? No. It’s an engineer’s evidence pipeline. Chargeback outcomes follow card-brand and issuer rules, and those are time-sensitive — confirm each rail’s current rules.
What’s the single biggest win? Prevention. 3DS2 plus solid fraud rules stop disputes from landing at all, which beats any cure.
What file types and sizes for Mercado Pago? .jpg, .png, or .pdf, with a 10 MB upload limit — confirm in MP’s current docs whether that applies per file or to the total. For Conekta, the size limit isn’t published — confirm it in their current docs.
How long until resolution? On Mercado Pago, up to 6 months depending on the card brand, and the disputed amount is held the entire time.
Prevention beats cure
The pipeline is worth building — it recovers real money you’d otherwise hand back on disputes you should have won. But be clear-eyed about the leverage: the biggest gains come before the dispute. Strong 3DS2 and fraud rules stop the contracargo from ever landing, and the evidence-log discipline that wins disputes is the same discipline that powers your fraud rules. For the prevention side and the Stripe equivalent of this whole flow, see Stripe disputes and chargebacks with Radar. For where these gateways fit in the broader Mexican landscape, see payment gateways in Mexico, and for more rail-specific builds, more LATAM payments guides.
Build the log, match the reason, submit before the deadline, and measure the leak — but confirm each rail’s current rules before you ship, because the provider specifics move.