Webhooks
Receive real-time notifications when events occur.
Overview
Configure webhook endpoints to receive real-time POST notifications when events occur in your account.
Registering KYC webhooks
KYC webhooks are configured from the Hodle dashboard, not by manually creating an Avenia webhook:
- Open API Keys → Webhooks and click Configurar Webhook.
- Enter your public HTTPS endpoint and select
KYC_APPROVED,KYC_REJECTED,KYC_EXPIRED, orKYC_FAILED. - Submit the form. Hodler validates the endpoint and idempotently activates the
KYCnotification subscription on the Avenia webhook that feeds/avenia/webhookbefore saving the customer webhook.
If Avenia cannot activate the subscription, the customer webhook is not saved;
retry after the provider configuration is available. The Avenia subscription is
shared by the platform and is created only once; it is not necessary to register
/avenia/webhook manually in the Avenia dashboard.
Events
| Event | Description |
|---|---|
DEPOSIT_ASSET_SUCCESS | A deposit was completed successfully. |
PAYOUT_SUCCESSFUL | A PIX payout was sent successfully. |
PAYOUT_FAILED | A PIX payout failed. |
PAYOUT_REFUNDED | A settled payout was reversed and refunded. |
KYC_APPROVED | KYC for an end-user was approved. |
KYC_REJECTED | KYC for an end-user was rejected. |
KYC_EXPIRED | KYC attempt expired without submission. |
KYC_FAILED | KYC attempt failed during processing. |
DISPUTE_CREATED | A PIX you received was contested (MED opened). |
DISPUTE_ACCEPTED | The contestation was accepted — the amount is returned to the payer. |
DISPUTE_REJECTED | The contestation was rejected — the amount stays with you. |
DISPUTE_CANCELED | The contestation was withdrawn before a decision. |
Payload
Every webhook delivery sends a JSON body with the following structure:
{
"event": "PAYOUT_SUCCESSFUL",
"data": { ... }
}Headers
Each request includes these headers for verification:
| Header | Description |
|---|---|
X-Hodle-Signature | HMAC-SHA256 signature of the payload, hex-encoded. |
X-Hodle-Timestamp | Unix timestamp (seconds) when the request was sent. |
Plus any custom headers you configured on the webhook.
Webhook Secret
Every webhook has its own signing secret, generated by Hodle when the webhook
is created. It is a 64-character hex string used as the HMAC key for the
X-Hodle-Signature header.
Where to find it:
- At creation — the secret is shown in the success screen right after you create the webhook in API Keys → Webhooks. Copy it and store it in your secret manager.
- Anytime after — open API Keys → Webhooks in the dashboard and click the eye icon on the webhook's Secret column to reveal or copy it.
Each webhook has a different secret. If you register multiple webhooks (e.g. one per event), verify each delivery with the secret of the webhook that received it. Treat the secret like a password: never commit it, never log it, and never expose it to a browser or mobile client.
Verifying Signatures
Always verify X-Hodle-Signature before trusting a delivery. The signature is
computed as:
signature = hex( HMAC_SHA256( secret, "<X-Hodle-Timestamp>.<raw request body>" ) )Three rules to verify it safely:
- Use the raw request body — the exact bytes received, before any JSON parsing or re-serialization. Re-stringifying the parsed JSON can reorder keys or change whitespace and will not match.
- Compare with a constant-time function (
crypto.timingSafeEqual,hash_equals,hmac.compare_digest). A plain===comparison leaks timing information that lets an attacker forge signatures byte by byte. - Reject stale timestamps — the timestamp is part of the signed content, so replaying an old request also replays its old timestamp. Reject deliveries older than 5 minutes to prevent replay attacks.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_IN_SECONDS = 300
type VerifyHodleWebhookArgs = {
rawBody: string
signature: string
timestamp: string
secret: string
}
const verifyHodleWebhook = (args: VerifyHodleWebhookArgs): boolean => {
const { rawBody, signature, timestamp, secret } = args
const timestampAge = Math.abs(
Math.floor(Date.now() / 1000) - Number(timestamp),
)
if (!Number.isFinite(timestampAge) || timestampAge > TOLERANCE_IN_SECONDS) {
return false
}
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest()
const received = Buffer.from(signature, 'hex')
if (received.length !== expected.length) {
return false
}
return timingSafeEqual(expected, received)
}import express from 'express'
const app = express()
app.post(
'/hodle/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const isValid = verifyHodleWebhook({
rawBody: req.body.toString('utf8'),
signature: req.header('X-Hodle-Signature') ?? '',
timestamp: req.header('X-Hodle-Timestamp') ?? '',
secret: process.env.HODLE_WEBHOOK_SECRET ?? '',
})
if (!isValid) {
return res.status(401).send('invalid signature')
}
const payload = JSON.parse(req.body.toString('utf8'))
// handle payload.event / payload.data
return res.status(200).send('ok')
},
)<?php
function verifyHodleWebhook(
string $rawBody,
string $signature,
string $timestamp,
string $secret,
): bool {
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HODLE_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_HODLE_TIMESTAMP'] ?? '';
if (!verifyHodleWebhook($rawBody, $signature, $timestamp, getenv('HODLE_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}Respond 2xx only after the signature check passes. Never skip
verification "just in dev" — a webhook endpoint without signature validation
accepts forged deposit and payout notifications from anyone who discovers the
URL.
The registration test delivery
When you register a webhook, Hodle immediately sends a signed test delivery
("event": "WEBHOOK_TEST") to the URL and only saves the webhook if it
responds 2xx. This first request arrives before the dashboard has shown
you the secret, so your endpoint cannot verify it yet. Handle it like this:
- Respond
2xxtoWEBHOOK_TESTevents without taking any action — never credit orders or move state on a test event. - After the webhook is created, copy the secret from the dashboard and verify the signature of every real event before processing it.
Retry Policy
Webhook deliveries are attempted once. A response with a 2xx status code is considered successful. Non-2xx responses are logged as failures.
PAYOUT_SUCCESSFUL
Sent when a PIX payout completes successfully. The same event and payload shape is emitted for every payout funding source — a paid Lightning invoice and a stablecoin-funded /api/wallet/payout (USDT/USDC/BRLA on Polygon, Base, or Tron).
Reading the payload for a stablecoin payout. The payload is Lightning-shaped for historical reasons. For a
wallet/payout(stablecoin) payout:
invoicecarries the on-chain transaction id (tx hash), not a Lightning bolt11.valueInSatoshisand everyquote.btc*/quote.satoshisfield are synthetic — the BRL amount converted at the current BTC rate. They are not meaningful for a stablecoin payout.- Use
valueInBrl,fee,pixKey, andquote.brlAmountas the source of truth.
{
"event": "PAYOUT_SUCCESSFUL",
"data": {
"success": true,
"invoice": "0xeafe9c4985963a7a7d6e49f763cca5c6006693031402d46c0da2fced4519fe03",
"valueInSatoshis": 13800,
"pixKey": "[email protected]",
"valueInBrl": "50.00",
"fee": "2.75",
"endToEndId": "E12345678202604281432abcdef123456",
"receipt": {
"endToEndId": "E12345678202604281432abcdef123456",
"paidAt": "2026-04-28T14:32:11.708Z",
"rail": "configured-rail",
"amountInBrl": "50.00",
"payerIspb": "12345678",
"receiver": {
"name": "MARIA SOUZA",
"taxId": "***.241.413-**",
"pixKey": "[email protected]",
"bankName": "Example Bank",
"ispb": "54811417",
"branch": "0001",
"account": "****5716",
"accountType": "TRAN"
}
},
"quote": {
"brlAmount": "50.00",
"btcAmount": 0.000138,
"satoshis": 13800,
"btcToBrlRate": 362069.04
}
}
}{
"event": "PAYOUT_SUCCESSFUL",
"data": {
"success": true,
"invoice": "lnbc32310n1p5u2g2qsp5xq6j2rhspx7es5c0dymwrn2wcam6ay2vpgft65njm9pe6te93fgqpp57yym5t9vqw85a8zszx2e59xhr69yum0dd9udz26vgzgptwtpfplshp5uwcvgs5clswpfxhm7nyfjmaeysn6us0yvjdexn9yjkv3k7zjhp2sxq9z0rgqcqpnrzjqvmhlzgnvate6rxlc2pnhser58gp298w5sx53n8gd5c78xpz8cxtxrj7rvqqgfqqqqqqqqqqqqqq05qqyg9qxpqysgqdn0n0h52k8wv5mvsy5lm4ew075u24g2nr6ys7sn5276me8583arkyahkv25d08ngvwx0cwdre3eg6jtjpus4j7xs0gznctwzmtjk20qql2p832",
"valueInSatoshis": 276190,
"pixKey": "13d3109f-3a1e-4c56-b76d-d2db7213b9f2",
"valueInBrl": "1000.00",
"fee": "170.00",
"quote": {
"brlAmount": "1000.00",
"btcAmount": 0.0027619041618037344,
"satoshis": 276190,
"btcToBrlRate": 362069.04418686405
}
}
}Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Whether the payout was successful. |
invoice | string | Lightning-funded: the bolt11 invoice paid. Stablecoin-funded: the on-chain transaction id (tx hash). |
valueInSatoshis | number | Amount in satoshis. Synthetic for stablecoin payouts (BRL converted at the BTC rate) — prefer valueInBrl. |
pixKey | string | The PIX key where BRL was sent. |
valueInBrl | string | Value in BRL. Source of truth for stablecoin payouts. |
fee | string | Fee charged in BRL. |
quote.brlAmount | string | BRL amount quoted. |
quote.btcAmount | number | BTC amount. Synthetic for stablecoin payouts. |
quote.satoshis | number | Amount in satoshis. Synthetic for stablecoin payouts. |
quote.btcToBrlRate | number | BTC to BRL exchange rate at the time. |
endToEndId | string | Bacen end-to-end id of the settled PIX. null if the rail did not report one. |
receipt | object | Receipt detail of the settled PIX — see Receipt detail. Absent on Lightning-funded payouts. |
Receipt detail
receipt carries the same facts a Brazilian comprovante prints, so you can
render your own receipt without asking us for a PDF. It is built from the rail's
own settlement message — nothing is inferred — so every field is nullable
and a rail that stayed silent about a field reports null instead of a guess.
| Field | Type | Description |
|---|---|---|
endToEndId | string | Bacen end-to-end id, the identifier the payee's bank shows for this PIX. |
paidAt | string | ISO-8601 settlement time reported by the rail. |
rail | string | Identifier of the rail that settled the PIX. |
amountInBrl | string | Amount that left, in BRL. |
payerIspb | string | ISPB of the institution that debited the funds, read from the end-to-end id. |
receiver.name | string | Payee name as resolved by the receiving bank. |
receiver.taxId | string | Payee tax id. A CPF is masked (***.241.413-**); a CNPJ is public registry data and comes whole. |
receiver.pixKey | string | PIX key the transfer was addressed to. |
receiver.bankName | string | Payee bank name when supplied by the rail; otherwise null. |
receiver.ispb | string | ISPB of the payee's institution — resolve the name with Bacen's participant list. |
receiver.branch | string | Payee branch (agência). |
receiver.account | string | Payee account, masked to the last four digits. |
receiver.accountType | string | Account type as reported by the rail (e.g. TRAN). |
Webhook deliveries are attempted once. The same receipt object is also
returned by GET /api/wallet/payout/:transactionId, so
a delivery your endpoint missed is never lost — poll for it instead.
The end-to-end id case can vary by rail. Compare it case-insensitively.
PAYOUT_FAILED
Sent when a PIX payout fails. For a stablecoin-funded /api/wallet/payout, the debited on-chain funds are auto-refunded to the user's wallet when the AUTO_REFUND flag is enabled. As with the success event, invoice carries the on-chain transaction id for stablecoin payouts.
{
"event": "PAYOUT_FAILED",
"data": {
"success": false,
"invoice": "0xeafe9c...",
"pixKey": "[email protected]",
"valueInBrl": "10.00",
"errorCode": "PIX_KEY_NOT_FOUND",
"errorDescription": "PIX key not found"
}
}{
"event": "PAYOUT_FAILED",
"data": {
"success": false,
"invoice": "lnbc10u1pj...",
"valueInSatoshis": 2450,
"pixKey": "[email protected]",
"valueInBrl": "10.00",
"fee": "0.20",
"error": "PIX key not found"
}
}PAYOUT_REFUNDED
Sent when a payout that had already left our side is undone and the value is given back to the user. This is different from PAYOUT_FAILED: PAYOUT_FAILED means the PIX never completed, while PAYOUT_REFUNDED means it did complete (or was settled) and was later reversed.
There are two situations that emit it:
| Situation | What happened |
|---|---|
| Reversed PIX (MED) | The recipient's bank returned the PIX (a MED / dispute / reversal). The BRL comes back to us and we refund the user on-chain. |
| Manual refund | Our team refunds a stuck or disputed payout from the backoffice, either on-chain (stablecoin) or over Lightning. |
Because the funding source differs, the data payload is not the same shape in all three cases. Always branch on the fields that are present, not on their position. The fields that are always present are success, pixKey, and valueInBrl.
Reversed PIX (MED)
The recipient's PSP returned the PIX. The returned BRL lands as BRLA in the user's sub-account, and when the AUTO_REFUND flag is enabled we send the same value back to the user's smart account on the original network.
{
"event": "PAYOUT_REFUNDED",
"data": {
"success": true,
"transactionId": "6650b21c9f4d3a0012ab34cd",
"endToEndId": "E1234567820260731113000abcdef123",
"pixKey": "[email protected]",
"valueInBrl": "50.00",
"returnedAmount": "50.00",
"originalTicketId": "7f3c1b2a-9d84-4c1e-8f20-1a2b3c4d5e6f",
"reversalTicketId": "b1e4d9c7-2a55-4f83-91cd-77e0a1b2c3d4",
"reason": "Payout reversed. Original ticket id: 7f3c1b2a-9d84-4c1e-8f20-1a2b3c4d5e6f",
"refundTxId": "0xeafe9c4985963a7a7d6e49f763cca5c6006693031402d46c0da2fced4519fe03",
"refundAddress": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
}
}| Field | Type | Description |
|---|---|---|
success | boolean | Always true — the event reports a completed reversal, not a failure. |
transactionId | string | Id of the original payout transaction. |
endToEndId | string | null | PIX end-to-end id of the original payout, when we have it. |
pixKey | string | PIX key the original payout was sent to. |
valueInBrl | string | Value of the original payout in BRL. |
returnedAmount | string | Amount actually returned by the reversal (BRLA). May be empty if the provider omits it, and may be less than valueInBrl on a partial return. |
originalTicketId | string | Provider ticket id of the original payout. |
reversalTicketId | string | Provider ticket id of the reversal itself. Use it to deduplicate. |
reason | string | Free-text reason from the provider describing the reversal. |
refundTxId | string | null | On-chain tx hash of the refund we sent the user. null when no refund was sent. |
refundAddress | string | null | Address that received the refund. null when no refund was sent. |
refundTxId and refundAddress are null whenever the automatic refund did not go out — the AUTO_REFUND flag is off, the run was a dry run, the returned asset/network is not refundable automatically, or the reversal landed outside the user's sub-account. The event is still sent, because the reversal itself is real and you need to know about it. Treat refundTxId: null as "reversed, refund pending manual review", not as "nothing happened".
Manual stablecoin refund
Our team refunded the payout on-chain from the backoffice.
{
"event": "PAYOUT_REFUNDED",
"data": {
"success": true,
"transactionId": "6650b21c9f4d3a0012ab34cd",
"refundTxId": "0xeafe9c4985963a7a7d6e49f763cca5c6006693031402d46c0da2fced4519fe03",
"refundAddress": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
"asset": "USDT",
"network": "polygon",
"stableAmount": "9.85",
"pixKey": "[email protected]",
"valueInBrl": "50.00",
"refundedAt": "2026-07-31T11:30:00.000Z"
}
}| Field | Type | Description |
|---|---|---|
transactionId | string | Id of the original payout transaction. |
refundTxId | string | On-chain tx hash of the refund. |
refundAddress | string | Address that received the refund. |
asset | string | Refunded asset (USDT, USDC, USDCE, BRLA). |
network | string | Network of the refund (polygon or base). |
stableAmount | string | Amount refunded in the stablecoin's own unit. |
pixKey | string | PIX key of the original payout. |
valueInBrl | string | Value of the original payout in BRL. |
refundedAt | string | ISO-8601 timestamp of the refund. |
Manual Lightning refund
The original payout was funded by a Lightning invoice, and the refund was paid back over Lightning.
{
"event": "PAYOUT_REFUNDED",
"data": {
"success": true,
"invoice": "lnbc32310n1p5u2g2qsp5xq6j2rhspx7es5c0dymwrn2wcam6ay2vpgft65njm9pe6te93fgqpp5...",
"refundAddress": "[email protected]",
"refundInvoice": "lnbc27619n1p5abcd2qsp5...",
"refundTxId": "5f8a1c2d3e4b5a6c7d8e9f0a1b2c3d4e",
"valueInSatoshis": 276190,
"pixKey": "13d3109f-3a1e-4c56-b76d-d2db7213b9f2",
"valueInBrl": "1000.00",
"refundedAt": "2026-07-31T11:30:00.000Z"
}
}| Field | Type | Description |
|---|---|---|
invoice | string | The original bolt11 invoice that funded the payout. |
refundAddress | string | null | Lightning address the refund was sent to, when one was registered. |
refundInvoice | string | The bolt11 invoice we paid to refund the user. |
refundTxId | string | Lightning payment id of the refund. |
valueInSatoshis | number | Amount refunded, in satoshis. |
pixKey | string | PIX key of the original payout. |
valueInBrl | string | Value of the original payout in BRL. |
refundedAt | string | ISO-8601 timestamp of the refund. |
Handling the event
type PayoutRefundedData = {
transactionId?: string
reversalTicketId?: string
refundTxId?: string | null
refundInvoice?: string
valueInBrl: string
pixKey: string
}
const handlePayoutRefunded = (data: PayoutRefundedData) => {
if (data.reversalTicketId) {
return reverseOrder({
orderRef: data.transactionId,
dedupeKey: data.reversalTicketId,
refundPending: !data.refundTxId,
})
}
return reverseOrder({
orderRef: data.transactionId,
dedupeKey: data.refundTxId ?? data.refundInvoice,
refundPending: false,
})
}Recommended handling:
- Be idempotent. Deduplicate on
reversalTicketIdfor reversals and onrefundTxIdfor manual refunds. A reversal is only processed once on our side, but your endpoint can still receive a duplicate delivery. - Reconcile against the original payout.
transactionId(andoriginalTicketIdfor reversals) ties the event back to thePAYOUT_SUCCESSFULyou already processed — undo whatever you did there. - Do not credit your user on
refundTxId: null. The money has not moved yet in that case; wait for our manual follow-up. - Use
returnedAmount, notvalueInBrl, to size the reversal on a MED, since a partial return is possible.
Identifying the sub-account
KYC events for a sub-account you created with POST /kyc/submit or
POST /kyb/submit carry two extra fields so you can tie the event back to the
end user it belongs to:
| Field | Type | Description |
|---|---|---|
subAccountId | string | null | The Avenia sub-account the KYC belongs to. |
userId | string | null | The Hodle user id of the sub-account. |
Both are null for KYC on your own account, where the event refers to the
platform account itself.
KYC_APPROVED
Sent when KYC finishes and is approved by the provider.
{
"event": "KYC_APPROVED",
"data": {
"attemptId": "att_a1b2c3...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"userId": "6a70b6364c38f215e99b8c8b",
"level": "level-1",
"approvedAt": "2026-05-08T22:31:11.000Z"
}
}After this event, /api/deposit/asset and /api/wallet/payout become available.
KYC_REJECTED
Sent when the KYC provider rejects the documents.
{
"event": "KYC_REJECTED",
"data": {
"attemptId": "att_a1b2c3...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"userId": "6a70b6364c38f215e99b8c8b",
"reason": "DOCUMENT_MISMATCH"
}
}KYC_EXPIRED
Sent when a hosted KYC attempt times out before submission.
{
"event": "KYC_EXPIRED",
"data": {
"attemptId": "att_a1b2c3...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"userId": "6a70b6364c38f215e99b8c8b",
"expiredAt": "2026-05-08T22:31:11.000Z"
}
}KYC_FAILED
Sent when an internal error occurs while processing a KYC attempt.
{
"event": "KYC_FAILED",
"data": {
"attemptId": "att_a1b2c3...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"userId": "6a70b6364c38f215e99b8c8b",
"error": "Internal provider error"
}
}DEPOSIT_ASSET_SUCCESS
Sent when a deposit completes successfully.
{
"event": "DEPOSIT_ASSET_SUCCESS",
"data": {
"success": true,
"value": 5000,
"asset": "LIGHTNING",
"externalId": "my-order-123",
"fee": 100,
"fxRateAtTx": 362069.04
}
}Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Whether the deposit was successful. |
value | number | Amount in BRL cents. |
asset | string | Asset type (LIGHTNING, USDT, USDC, USDCE, BRLA, DEPIX, LBTC). |
externalId | string | The external ID sent in the deposit request, or an auto-generated UUID. |
fee | number | Fee charged in BRL cents. |
fxRateAtTx | number | Exchange rate at the time of the transaction. |
Dispute events (MED)
A MED (Mecanismo Especial de Devolução) is the Pix contestation the payer's bank opens when the payer reports the transfer as fraudulent. When a Pix that funded one of your operations is contested, we forward the contestation to you on every leg of its lifecycle:
| Event | What happened |
|---|---|
DISPUTE_CREATED | The MED was opened. The disputed amount is frozen on the receiving account. |
DISPUTE_ACCEPTED | The contestation was accepted — the amount goes back to the payer. |
DISPUTE_REJECTED | The contestation was rejected — the amount stays with you. |
DISPUTE_CANCELED | The 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) for the outcome.
A MED is opened after the asset has been delivered — delivery is
irreversible within seconds of the Pix, the contestation arrives days later.
DISPUTE_CREATED is therefore a fraud signal about the payer, not a payment
that can still be stopped. Use it to freeze the end user, not to retry the
order.
{
"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
| Field | Type | Description |
|---|---|---|
status | string | Normalized status: CREATED, ACCEPTED, REJECTED or CANCELED. Always matches the event. |
providerStatus | string | null | The provider's own status string (e.g. OPENED). Free-form — report it, never route on it. |
disputeId | string | null | Provider id of the dispute, when the provider sends one. |
endToEndId | string | End-to-end id of the contested Pix. This is the deduplication key — one MED per Pix. |
value | number | Contested amount in BRL cents. |
valueInBrl | string | Contested amount in BRL, decimal string. |
reason | string | null | Free-text reason given by the payer to their bank. |
payerName | string | null | Name of the payer who opened the contestation, when the provider sends it. |
occurredAt | string | ISO-8601 timestamp of this event. |
walletCharge | object | null | The operation the contested Pix funded. null when the Pix funded no charge — see below. |
walletCharge
The contestation payload carries no order reference of its own: a MED only
identifies the contested Pix by its endToEndId. We resolve the operation that
Pix paid for and send it inline, so you can reconcile the dispute against the
order you already settled without a second lookup.
| Field | Type | Description |
|---|---|---|
id | string | Hodle id of the charge. |
externalId | string | null | The external id you sent when creating the deposit — your order key. |
trackId | string | null | Hodle tracking id of the operation. |
correlationID | string | null | Provider correlation id of the Pix charge. |
asset | string | null | Asset delivered (USDT, USDC, BRLA, …). |
network | string | null | Network the asset was delivered on. |
status | string | null | Charge status at the time of the event (COMPLETED, FAILED, …). |
valueInBrl | string | Value of the charge in BRL. May differ from the contested amount on a partial MED. |
fee | string | Fee charged, in BRL. |
transactionHash | string | null | On-chain tx hash of the delivery, when there is one. |
createdAt | string | null | ISO-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.
Handling the event
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',
})
}Recommended handling:
- Deduplicate on
endToEndId. A Pix has exactly one MED, and the same leg can be redelivered. - Expect the legs out of order or missing. A MED may be canceled without ever being accepted or rejected, and a contestation can sit open for days.
- Do not reverse the delivery on
DISPUTE_CREATED. The asset already left — act on the end user (freeze, re-KYC, block), not on the blockchain. - Only
DISPUTE_ACCEPTEDcosts you the money.REJECTEDandCANCELEDboth leave the amount with you.