Disputes (MED)

How a contested PIX reaches you, what the four dispute events carry, and how to handle each leg.

What a MED is

A MED (Mecanismo Especial de Devolução) is the PIX contestation the payer's bank opens when the payer reports a transfer as fraudulent. Bacen gives the payer's bank a window measured in days to open it — long after the PIX settled and long after the asset was delivered.

That timing is the whole reason disputes need their own handling:

  1. A PIX lands on the account and funds one of your operations.
  2. The asset is delivered on-chain, irreversibly, within seconds.
  3. Days later the payer's bank opens a MED. The disputed amount is frozen on the receiving account.
  4. The MED is accepted, rejected, or withdrawn.

By the time you receive DISPUTE_CREATED, the money has already left the chain. The event is a fraud signal about the payer, not a payment you can still stop.

Do not treat a dispute as a reversible order. Act on the end user — freeze the account, force re-KYC, block further deposits — not on the blockchain.

Subscribing

Dispute events are registered like any other webhook, from the Hodle dashboard:

  1. Open API Keys → Webhooks and click Configurar Webhook.
  2. Enter your public HTTPS endpoint.
  3. Select the dispute events you want: DISPUTE_CREATED, DISPUTE_ACCEPTED, DISPUTE_REJECTED, DISPUTE_CANCELED.

Each event is a separate subscription, so you can register only the legs you act on. Signature verification, headers, and the secret are identical to every other event — see Webhooks.

The four events

EventWhat happened
DISPUTE_CREATEDThe MED was opened. The disputed amount is frozen on the receiving account.
DISPUTE_ACCEPTEDThe contestation was accepted — the amount goes back to the payer.
DISPUTE_REJECTEDThe contestation was rejected — the amount stays with you.
DISPUTE_CANCELEDThe contestation was withdrawn before any decision.

The four events carry the same payload shape, so one handler covers all of them; branch on event (or on data.status, which always matches it) for the outcome.

Only DISPUTE_ACCEPTED costs you the money. REJECTED and CANCELED both leave the amount with you.

Payload

DISPUTE_CREATED
{
  "event": "DISPUTE_CREATED",
  "data": {
    "status": "CREATED",
    "providerStatus": "OPENED",
    "disputeId": null,
    "endToEndId": "E00416968202608231253t7wOSElb8rU",
    "value": 10000,
    "valueInBrl": "100.00",
    "reason": "No intuito de recuperar meu primeiro investimento me pediu mais dinheiro",
    "payerName": null,
    "occurredAt": "2026-08-23T12:53:00.000Z",
    "walletCharge": {
      "id": "6650b21c9f4d3a0012ab34cd",
      "externalId": "my-order-123",
      "trackId": "9f4d3a0012ab34cd6650b21c",
      "correlationID": "3f1a9c7e-2b44-4d10-9a51-8c2d6e0f4b73",
      "asset": "USDT",
      "network": "polygon",
      "status": "COMPLETED",
      "valueInBrl": "100.00",
      "fee": "2.00",
      "transactionHash": "0xeafe9c4985963a7a7d6e49f763cca5c6006693031402d46c0da2fced4519fe03",
      "createdAt": "2026-08-23T12:52:41.000Z"
    }
  }
}

Fields

FieldTypeDescription
statusstringNormalized status: CREATED, ACCEPTED, REJECTED or CANCELED. Always matches the event.
providerStatusstring | nullThe provider's own status string (e.g. OPENED). Free-form — report it, never route on it.
disputeIdstring | nullProvider id of the dispute, when the provider sends one.
endToEndIdstringEnd-to-end id of the contested PIX. This is the deduplication key — one MED per PIX.
valuenumberContested amount in BRL cents.
valueInBrlstringContested amount in BRL, decimal string.
reasonstring | nullFree-text reason the payer gave their bank.
payerNamestring | nullName of the payer who opened the contestation, when the provider sends it.
occurredAtstringISO-8601 timestamp of this event.
walletChargeobject | nullThe operation the contested PIX funded. null when the PIX funded no charge — see below.

status is normalized by us and is the only field safe to branch on. providerStatus is the provider's own free-form string: it has no documented domain, so store it for support and never route logic on it.

walletCharge — reconciling against your order

A MED identifies nothing but the contested PIX, by its endToEndId. It carries no order id, no account id, and no reference to whatever the PIX paid for.

So we resolve it for you. Every incoming PIX has its endToEndId recorded on the operation it funded, and on a dispute we walk that back and send the operation inline, so you can match the dispute to the order you already settled without a second lookup.

FieldTypeDescription
idstringHodle id of the charge.
externalIdstring | nullThe external id you sent when creating the deposit — your order key.
trackIdstring | nullHodle tracking id of the operation.
correlationIDstring | nullProvider correlation id of the PIX charge.
assetstring | nullAsset delivered (USDT, USDC, BRLA, …).
networkstring | nullNetwork the asset was delivered on.
statusstring | nullCharge status at the time of the event (COMPLETED, FAILED, …).
valueInBrlstringValue of the charge in BRL. May differ from the contested amount on a partial MED.
feestringFee charged, in BRL.
transactionHashstring | nullOn-chain tx hash of the delivery, when there is one.
createdAtstring | nullISO-8601 creation timestamp of the charge.

walletCharge is null when the contested PIX did not fund a charge — a direct transfer into the account, or a PIX received before we started recording the endToEndId of incoming payments. The event is still delivered: match it by endToEndId against your own records in that case.

transactionHash is what proves the delivery happened. Keep it: it is the evidence that answers the contestation.

Attribution — and its one gap

Because the MED payload has no account identifier, we attribute the dispute by matching its endToEndId, in order, against:

  1. the PIX2STABLE delivery that consumed the PIX (which already knows its merchant account),
  2. the wallet charge the PIX paid,
  3. the transaction ledger entry for the PIX.

The first match wins and identifies the account — and therefore you, the customer the event is sent to.

If none of the three match, the dispute is still recorded on our side but there is no customer to forward it to, so no webhook is sent. In practice this means a PIX that never funded a recorded operation. If you are notified of a frozen amount you never received an event for, contact support with the endToEndId.

Attribution is resolved once, when the MED is first seen. Later legs (ACCEPTED, REJECTED, CANCELED) reuse it, so every leg of one MED is delivered to the same endpoint with the same walletCharge.

Delivery semantics

  • One delivery attempt per event. A 2xx is success; anything else is logged as a failure and not retried — same policy as every other Hodle webhook.
  • There is no dispute endpoint to poll. Unlike payouts, a dispute cannot be re-read over the API. Return 2xx fast and process asynchronously; a delivery your endpoint drops is recoverable only through support.
  • Deduplicate on endToEndId + status. A PIX has exactly one MED, but the provider can redeliver a leg, so the same (endToEndId, status) pair may arrive more than once.
  • Expect legs out of order, or missing. A MED may be canceled without ever being accepted or rejected, and it can sit open for days with no further event.

Handling the event

One handler for the four legs
type DisputeData = {
  status: 'CREATED' | 'ACCEPTED' | 'REJECTED' | 'CANCELED'
  endToEndId: string
  value: number
  reason: string | null
  walletCharge: { externalId: string | null } | null
}

const handleDispute = async (data: DisputeData): Promise<void> => {
  const orderRef = data.walletCharge?.externalId ?? null

  if (data.status === 'CREATED') {
    return flagOrderUnderDispute({
      dedupeKey: data.endToEndId,
      orderRef,
      amountInCents: data.value,
      reason: data.reason,
    })
  }

  return closeDispute({
    dedupeKey: data.endToEndId,
    orderRef,
    lostToPayer: data.status === 'ACCEPTED',
  })
}

Checklist

  • On DISPUTE_CREATED: flag the order, freeze the end user, and keep the transactionHash — it is your evidence.
  • Do not attempt to reverse or re-run the delivery. The asset is gone.
  • On DISPUTE_ACCEPTED: book the loss and treat the end user as confirmed fraud.
  • On DISPUTE_REJECTED / DISPUTE_CANCELED: close the flag. The money stayed with you.