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:

  1. Open API Keys → Webhooks and click Configurar Webhook.
  2. Enter your public HTTPS endpoint and select KYC_APPROVED, KYC_REJECTED, KYC_EXPIRED, or KYC_FAILED.
  3. Submit the form. Hodler validates the endpoint and idempotently activates the KYC notification subscription on the Avenia webhook that feeds /avenia/webhook before 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

EventDescription
DEPOSIT_ASSET_SUCCESSA deposit was completed successfully.
DEPOSIT_ASSET_FAILEDA paid deposit could not deliver the requested asset.
DEPOSIT_ASSET_REFUNDEDA partial or full PIX refund to the deposit's payer was confirmed.
PAYOUT_SUCCESSFULA PIX payout was sent successfully.
PAYOUT_FAILEDA PIX payout failed.
PAYOUT_REFUNDEDA settled payout was reversed and refunded.
KYC_APPROVEDKYC for an end-user was approved.
KYC_REJECTEDKYC for an end-user was rejected.
KYC_EXPIREDKYC attempt expired without submission.
KYC_FAILEDKYC attempt failed during processing.
DISPUTE_CREATEDA PIX you received was contested (MED opened).
DISPUTE_ACCEPTEDThe contestation was accepted — the amount is returned to the payer.
DISPUTE_REJECTEDThe contestation was rejected — the amount stays with you.
DISPUTE_CANCELEDThe 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:

HeaderDescription
X-Hodle-SignatureHMAC-SHA256 signature of the payload, hex-encoded.
X-Hodle-TimestampUnix 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:

  1. 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.
  2. 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:

  1. 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.
  2. 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.
  3. 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.
Node.js / TypeScript
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)
}
Express endpoint (raw body)
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
<?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 2xx to WEBHOOK_TEST events 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

A response with a 2xx status code is considered successful. Non-2xx responses and network errors are logged as failures.

DEPOSIT_ASSET_FAILED and DEPOSIT_ASSET_REFUNDED are persisted with the deposit. A worker checks for pending notifications every minute and retries failed deliveries. Delivery is at least once: deduplicate by data.eventId, which remains stable on retries. All subscribed endpoints are attempted; if one fails, an endpoint that already accepted the event may receive it again.

Other events follow their existing delivery paths. For deposit failure and refund events, verify the HMAC signature on every attempt and return 2xx after accepting the event, including an already processed duplicate. A webhook retry only redelivers the notification; it does not retry the deposit or send another refund.

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/BRS on Polygon, Base, Tron, or Solana).

Reconciling the event. externalId is the idempotency key you sent when creating the payout, echoed back, so you can match the event to your own order without polling GET /api/wallet/payout/{transactionId}. transactionId, trackId, asset, network and txHash identify the same payout the GET endpoint reports.

Legacy bitcoin fields. valueInSatoshis and quote.btcAmount / quote.satoshis / quote.btcToBrlRate are sent only when the payout asset is priced in bitcoin (a Lightning-funded payout). On a stablecoin payout they described nothing — fxRateAtTx is BRL per unit of the asset being sold, not per bitcoin — so they are absent. Use quote.assetAmount and quote.fxRateAtTx for the asset that actually funded the payout, and valueInBrl / fee as the source of truth for the money. invoice is kept as an alias of txHash: on a stablecoin payout it carries the on-chain transaction id, not a bolt11.

PAYOUT_SUCCESSFUL — stablecoin (wallet/payout)
{
  "event": "PAYOUT_SUCCESSFUL",
  "data": {
    "success": true,
    "transactionId": "65f1a8b2c3d4e5f6a7b8c9d0",
    "externalId": "offramp:823e6356",
    "trackId": "e38afb8e-7472-4100-9c69-dd619dda9b96",
    "status": "COMPLETED",
    "asset": "USDC",
    "network": "solana",
    "valueInBrl": "50.00",
    "fee": "2.75",
    "pixKey": "recipient@example.com",
    "endToEndId": "E12345678202604281432abcdef123456",
    "txHash": "2mZcnSeMEbYA8Rz6YNse6zkB7gsUcZsnvo5AhtgcF9DqHbGho3k9S16Y4Szd",
    "receipt": {
      "endToEndId": "E12345678202604281432abcdef123456",
      "paidAt": "2026-04-28T14:32:11.708Z",
      "rail": "WOOVI",
      "amountInBrl": "50.00",
      "payerIspb": "12345678",
      "receiver": {
        "name": "MARIA SOUZA",
        "taxId": "***.241.413-**",
        "pixKey": "recipient@example.com",
        "bankName": "Example Bank",
        "ispb": "54811417",
        "branch": "0001",
        "account": "****5716",
        "accountType": "TRAN"
      }
    },
    "receiptUrl": "https://receipts.hodle.com.br/receipts/65f1a8b2c3d4e5f6a7b8c9d0.pdf",
    "quote": {
      "brlAmount": "50.00",
      "assetAmount": "9.65281000",
      "fxRateAtTx": 5.17981
    },
    "invoice": "2mZcnSeMEbYA8Rz6YNse6zkB7gsUcZsnvo5AhtgcF9DqHbGho3k9S16Y4Szd"
  }
}
PAYOUT_SUCCESSFUL — Lightning-funded
{
  "event": "PAYOUT_SUCCESSFUL",
  "data": {
    "success": true,
    "transactionId": "65f1a8b2c3d4e5f6a7b8c9d1",
    "externalId": null,
    "trackId": "3dbbb785-8a1d-41c4-9d15-171e0ddc7c3d",
    "status": "COMPLETED",
    "asset": "LIGHTNING",
    "network": "lightning",
    "valueInBrl": "1000.00",
    "fee": "170.00",
    "pixKey": "13d3109f-3a1e-4c56-b76d-d2db7213b9f2",
    "endToEndId": "E12345678202604281432abcdef123457",
    "txHash": "lnbc32310n1p5u2g2qsp5xq6j2rhspx7es5c0dymwrn2wcam6ay2vpgft65njm9pe6te93fgqpp5...",
    "receipt": null,
    "receiptUrl": null,
    "valueInSatoshis": 276190,
    "quote": {
      "brlAmount": "1000.00",
      "assetAmount": "0.00276190",
      "fxRateAtTx": 362069.04418686405,
      "btcAmount": 0.0027619041618037344,
      "satoshis": 276190,
      "btcToBrlRate": 362069.04418686405
    },
    "invoice": "lnbc32310n1p5u2g2qsp5xq6j2rhspx7es5c0dymwrn2wcam6ay2vpgft65njm9pe6te93fgqpp5..."
  }
}

Fields

FieldTypeDescription
successbooleanAlways true on this event.
transactionIdstringOur id for the payout — the same one POST /api/wallet/payout returned.
externalIdstring | nullThe idempotency key you created the payout with. null when the payout was not created through the API.
trackIdstring | nullInternal tracking id of the payout.
statusstringAlways COMPLETED on this event.
assetstring | nullAsset debited to fund the payout (USDT, USDC, BRLA, BRS, LIGHTNING).
networkstring | nullNetwork the asset was debited on.
valueInBrlstringValue paid out, in BRL. Source of truth for the amount.
feestringFee charged, in BRL.
pixKeystring | nullThe PIX key where BRL was sent.
endToEndIdstring | nullBacen end-to-end id of the settled PIX. null if the rail did not report one.
txHashstring | nullOn-chain transaction id of the debit, or the bolt11 invoice on a Lightning-funded payout.
receiptobject | nullReceipt detail of the settled PIX — see Receipt detail. null when the rail reported none.
receiptUrlstring | nullPDF comprovante, when one was rendered.
quote.brlAmountstringBRL amount quoted.
quote.assetAmountstring | nullAmount in asset the BRL value converts to at fxRateAtTx.
quote.fxRateAtTxnumber | nullBRL per unit of asset at the time of the payout.
quote.btcAmountnumberBitcoin-priced payouts only. BTC amount.
quote.satoshisnumberBitcoin-priced payouts only. Amount in satoshis.
quote.btcToBrlRatenumberBitcoin-priced payouts only. BTC to BRL rate at the time.
valueInSatoshisnumberBitcoin-priced payouts only. Amount in satoshis. Absent on stablecoin payouts — use valueInBrl.
invoicestring | nullLegacy alias of txHash.

Receipt detail

receipt carries the same facts a Brazilian comprovante prints, so you can render your own receipt without asking us for a PDF. The webhook contains the details available at settlement. Missing recipient and settlement details are null; the optional fields below may also be absent in webhook and legacy receipts. Bank identifiers are strings and preserve leading zeros.

FieldTypeDescription
endToEndIdstringBacen end-to-end id, the identifier the payee's bank shows for this PIX.
paidAtstringISO-8601 settlement time reported by the rail.
railstringAVENIA or WOOVI, the rail that settled the PIX.
amountInBrlstringAmount that left, in BRL.
payerIspbstringISPB of the institution that debited the funds, read from the end-to-end id.
brCodestringOptional. Original PIX BR Code (copia e cola) for a QR payout, when available.
receiver.namestringPayee name as resolved by the receiving bank.
receiver.taxIdstringPayee tax id. A CPF is masked (***.241.413-**); a CNPJ is public registry data and comes whole.
receiver.pixKeystringPIX key the transfer was addressed to.
receiver.pixKeyTypestringOptional. PIX key type, such as EMAIL or CPF, when available.
receiver.bankNamestringPayee bank name when supplied by the rail; otherwise null.
receiver.bankCodestringOptional. Bank code supplied by the rail, such as 001; distinct from the ISPB.
receiver.ispbstringISPB of the payee's institution — resolve the name with Bacen's participant list.
receiver.branchstringPayee branch (agência).
receiver.accountstringPayee account, masked to the last four digits.
receiver.accountTypestringAccount type as reported by the rail (e.g. TRAN).

These payout deliveries are attempted once. For the latest persisted receipt, use GET /api/wallet/payout/:transactionId. Its receipt uses this format and may contain additional details obtained after the webhook. The GET response also reports the normalized creditParty, receiptUrl, and receiptComplete; receipt enrichment does not emit another PAYOUT_SUCCESSFUL event.

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.

PAYOUT_FAILED — stablecoin (wallet/payout)
{
  "event": "PAYOUT_FAILED",
  "data": {
    "success": false,
    "invoice": "0xeafe9c...",
    "pixKey": "user@email.com",
    "valueInBrl": "10.00",
    "errorCode": "PAYOUT_FAILED",
    "errorDescription": "Não foi possível processar o pagamento no momento. Tente novamente mais tarde."
  }
}

errorCode and errorDescription are stable, provider-agnostic values. Provider names, provider codes, processing stages and raw provider messages are never included in this event.

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:

SituationWhat 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 refundOur 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.

PAYOUT_REFUNDED — reversed PIX
{
  "event": "PAYOUT_REFUNDED",
  "data": {
    "success": true,
    "transactionId": "6650b21c9f4d3a0012ab34cd",
    "endToEndId": "E1234567820260731113000abcdef123",
    "pixKey": "recipient@example.com",
    "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"
  }
}
FieldTypeDescription
successbooleanAlways true — the event reports a completed reversal, not a failure.
transactionIdstringId of the original payout transaction.
endToEndIdstring | nullPIX end-to-end id of the original payout, when we have it.
pixKeystringPIX key the original payout was sent to.
valueInBrlstringValue of the original payout in BRL.
returnedAmountstringAmount actually returned by the reversal (BRLA). May be empty if the provider omits it, and may be less than valueInBrl on a partial return.
originalTicketIdstringProvider ticket id of the original payout.
reversalTicketIdstringProvider ticket id of the reversal itself. Use it to deduplicate.
reasonstringFree-text reason from the provider describing the reversal.
refundTxIdstring | nullOn-chain tx hash of the refund we sent the user. null when no refund was sent.
refundAddressstring | nullAddress 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.

PAYOUT_REFUNDED — manual stablecoin refund
{
  "event": "PAYOUT_REFUNDED",
  "data": {
    "success": true,
    "transactionId": "6650b21c9f4d3a0012ab34cd",
    "refundTxId": "0xeafe9c4985963a7a7d6e49f763cca5c6006693031402d46c0da2fced4519fe03",
    "refundAddress": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
    "asset": "USDT",
    "network": "polygon",
    "stableAmount": "9.85",
    "pixKey": "user@email.com",
    "valueInBrl": "50.00",
    "refundedAt": "2026-07-31T11:30:00.000Z"
  }
}
FieldTypeDescription
transactionIdstringId of the original payout transaction.
refundTxIdstringOn-chain tx hash of the refund.
refundAddressstringAddress that received the refund.
assetstringRefunded asset (USDT, USDC, USDCE, BRLA).
networkstringNetwork of the refund (polygon or base).
stableAmountstringAmount refunded in the stablecoin's own unit.
pixKeystringPIX key of the original payout.
valueInBrlstringValue of the original payout in BRL.
refundedAtstringISO-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.

PAYOUT_REFUNDED — manual Lightning refund
{
  "event": "PAYOUT_REFUNDED",
  "data": {
    "success": true,
    "invoice": "lnbc32310n1p5u2g2qsp5xq6j2rhspx7es5c0dymwrn2wcam6ay2vpgft65njm9pe6te93fgqpp5...",
    "refundAddress": "user@walletofsatoshi.com",
    "refundInvoice": "lnbc27619n1p5abcd2qsp5...",
    "refundTxId": "5f8a1c2d3e4b5a6c7d8e9f0a1b2c3d4e",
    "valueInSatoshis": 276190,
    "pixKey": "13d3109f-3a1e-4c56-b76d-d2db7213b9f2",
    "valueInBrl": "1000.00",
    "refundedAt": "2026-07-31T11:30:00.000Z"
  }
}
FieldTypeDescription
invoicestringThe original bolt11 invoice that funded the payout.
refundAddressstring | nullLightning address the refund was sent to, when one was registered.
refundInvoicestringThe bolt11 invoice we paid to refund the user.
refundTxIdstringLightning payment id of the refund.
valueInSatoshisnumberAmount refunded, in satoshis.
pixKeystringPIX key of the original payout.
valueInBrlstringValue of the original payout in BRL.
refundedAtstringISO-8601 timestamp of the refund.

Handling the event

Branching on the refund shape
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 reversalTicketId for reversals and on refundTxId for 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 (and originalTicketId for reversals) ties the event back to the PAYOUT_SUCCESSFUL you 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, not valueInBrl, 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:

FieldTypeDescription
subAccountIdstring | nullThe Avenia sub-account the KYC belongs to.
userIdstring | nullThe 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.

KYC_APPROVED
{
  "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.

KYC_REJECTED
{
  "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.

KYC_EXPIRED
{
  "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.

KYC_FAILED
{
  "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.

DEPOSIT_ASSET_SUCCESS
{
  "event": "DEPOSIT_ASSET_SUCCESS",
  "data": {
    "success": true,
    "value": 5000,
    "asset": "LIGHTNING",
    "externalId": "my-order-123",
    "fee": 100,
    "fxRateAtTx": 362069.04
  }
}

Fields

FieldTypeDescription
successbooleanWhether the deposit was successful.
valuenumberAmount in BRL cents.
assetstringAsset type (LIGHTNING, USDT, USDC, USDCE, BRLA).
externalIdstringThe external ID sent in the deposit request, or an auto-generated UUID.
feenumberFee charged in BRL cents.
fxRateAtTxnumberExchange rate at the time of the transaction.

Who paid the PIX

Accounts that own a virtual account also get the identity of whoever actually paid the PIX charge, so you can reconcile the deposit against your own customer without asking us.

DEPOSIT_ASSET_SUCCESS (own virtual account)
{
  "event": "DEPOSIT_ASSET_SUCCESS",
  "data": {
    "success": true,
    "asset": "USDT",
    "network": "polygon",
    "externalId": "my-order-123",
    "finalDestinationKind": "DIRECT",
    "valueInBrl": "50.00",
    "valueInCurrency": 8.82,
    "fee": "1.00",
    "fxRateAtTx": 5.55,
    "trackId": "trk_abc123",
    "payer": {
      "name": "FULANO DE TAL",
      "taxId": "12345678901"
    },
    "endToEndId": "E1823612020260830120000000000001",
    "paidAt": "2026-08-30T12:00:00.000Z"
  }
}
FieldTypeDescription
payer.namestring | nullName of whoever paid the PIX, as the bank reports it.
payer.taxIdstring | nullCPF/CNPJ of the payer, digits only.
endToEndIdstring | nullEnd-to-end id of the PIX that funded the deposit.
paidAtstring | nullISO-8601 timestamp of when the PIX settled.

The three fields are only present when your account owns a virtual account — there the PIX lands in an account opened in your name and the payer is your own customer. On the shared Hodle account the payer is our counterparty and the fields are omitted entirely. Ask support to enable it if you need them.

The same data is available at any time from GET /api/deposit/asset/{externalId}.

Deposit failure and refund

Subscribe separately to DEPOSIT_ASSET_FAILED and DEPOSIT_ASSET_REFUNDED in API Keys → Webhooks. Failure notifications currently cover the Woovi stablecoin delivery flow, including PIX → Lightning. Refund notifications require independent verification of the original PIX refund with Woovi. Receiving the PIX does not mean the requested asset was delivered.

Both events include these fields in data, alongside their event-specific fields:

FieldTypeDescription
eventIdstringStable notification id. Treat it as opaque and use it to deduplicate retries.
occurredAtstringISO-8601 time of the failure or confirmed refund, not the HTTP delivery time.
externalIdstringExternal id of the original deposit, used to match your order.
walletChargestringHodle id of the original deposit charge.
trackIdstringTracking id of the deposit; may be absent if none was recorded.
assetstringAsset requested in the deposit, such as LIGHTNING or USDT. A PIX refund returns BRL to the payer.
networkstringNetwork requested in the deposit, such as lightning or polygon.
valuenumberOriginal deposit amount in BRL cents: 2000 means R$20.00.
valueInBrlstringOriginal deposit amount in BRL, formatted with two decimal places.

DEPOSIT_ASSET_FAILED

Sent when a paid deposit definitively fails to deliver the requested asset. retryable: false means the customer must wait for a full refund before starting another purchase. This event does not confirm that any money has been returned.

DEPOSIT_ASSET_FAILED
{
  "event": "DEPOSIT_ASSET_FAILED",
  "data": {
    "eventId": "deposit-failed-65f1a8b2c3d4e5f6a7b8c9d0",
    "occurredAt": "2026-09-11T02:00:00.000Z",
    "externalId": "order-example",
    "walletCharge": "65f1a8b2c3d4e5f6a7b8c9d0",
    "trackId": "example-track",
    "asset": "LIGHTNING",
    "network": "lightning",
    "value": 2000,
    "valueInBrl": "20.00",
    "success": false,
    "status": "FAILED",
    "errorCode": "DEPOSIT_DELIVERY_FAILED",
    "errorDescription": "Não foi possível concluir a entrega. Aguarde a confirmação do estorno antes de tentar novamente.",
    "retryable": false
  }
}

Failure fields

FieldTypeDescription
successbooleanAlways false: delivery failed.
statusstringAlways FAILED.
errorCodestringDEPOSIT_DELIVERY_FAILED.
errorDescriptionstringCustomer-safe explanation. Raw provider errors are not included.
retryablebooleanAlways false. A failure alone does not permit another purchase.

Timeouts and ambiguous settlements remain PROCESSING for reconciliation. They do not emit this definitive failure event or permit a second payment.

DEPOSIT_ASSET_REFUNDED

Sent after a PIX refund to the original deposit's payer is independently confirmed. It reports both partial and full refunds. A return from a conversion provider to Hodle does not qualify as a PIX refund to the payer, and this notification does not initiate an automatic refund.

Full refund

In this example the original R$20.00 deposit was fully returned to the payer:

DEPOSIT_ASSET_REFUNDED — full refund
{
  "event": "DEPOSIT_ASSET_REFUNDED",
  "data": {
    "eventId": "deposit-refund-65f1a8b2c3d4e5f6a7b8c9d0-D00000000202609110205example000001",
    "occurredAt": "2026-09-11T02:05:00.000Z",
    "externalId": "order-example",
    "walletCharge": "65f1a8b2c3d4e5f6a7b8c9d0",
    "trackId": "example-track",
    "asset": "LIGHTNING",
    "network": "lightning",
    "value": 2000,
    "valueInBrl": "20.00",
    "success": true,
    "status": "REFUNDED",
    "refundStatus": "CONFIRMED",
    "refundValue": 2000,
    "refundedValue": 2000,
    "refundEndToEndId": "D00000000202609110205example000001",
    "partial": false,
    "retryable": true
  }
}

Partial refund

Here only R$5.00 of a failed R$20.00 deposit has been returned. The charge keeps its previous status (FAILED in this example), and retryable remains false:

DEPOSIT_ASSET_REFUNDED — partial refund
{
  "event": "DEPOSIT_ASSET_REFUNDED",
  "data": {
    "eventId": "deposit-refund-65f1a8b2c3d4e5f6a7b8c9d1-D00000000202609110205example000002",
    "occurredAt": "2026-09-11T02:05:00.000Z",
    "externalId": "order-partial-example",
    "walletCharge": "65f1a8b2c3d4e5f6a7b8c9d1",
    "trackId": "example-partial-track",
    "asset": "LIGHTNING",
    "network": "lightning",
    "value": 2000,
    "valueInBrl": "20.00",
    "success": true,
    "status": "FAILED",
    "refundStatus": "CONFIRMED",
    "refundValue": 500,
    "refundedValue": 500,
    "refundEndToEndId": "D00000000202609110205example000002",
    "partial": true,
    "retryable": false
  }
}

Refund fields

FieldTypeDescription
successbooleanAlways true: this refund was confirmed. It does not mean the requested asset was delivered or the entire deposit was refunded.
statusstringREFUNDED when cumulative confirmed refunds equal the original deposit amount. Otherwise, the charge's previous status.
refundStatusstringAlways CONFIRMED for the individual refund reported by this event, including a partial refund.
refundValuenumberAmount of this individual refund in BRL cents.
refundedValuenumberCumulative confirmed refunds for the deposit in BRL cents at verification time.
refundEndToEndIdstringPIX end-to-end id of this refund. It identifies the return, not the original payment.
partialbooleantrue while cumulative confirmed refunds are less than value; false once the entire deposit has been returned.
retryablebooleantrue only after a full confirmed refund. The customer may start a new purchase; this does not retry the original deposit.

If a R$20.00 deposit is returned in two refunds of R$5.00 and R$15.00, the final event has refundValue: 1500, refundedValue: 2000, partial: false, and retryable: true. Use refundedValue as the cumulative total; do not add it to the previous total or count a repeated eventId twice.

Handling failures and refunds

Verify the signature and deduplicate by data.eventId before updating the order identified by externalId. Do not require a prior DEPOSIT_ASSET_FAILED delivery to accept a confirmed refund. Show a delivery failure or partial refund as awaiting resolution, and offer a new purchase only when retryable is true. See the retry policy for redelivery behavior.

GET /api/deposit/asset/{externalId} returns the latest failure and refund state for the authenticated owner. Keep polling a failed deposit while waiting for its refund. On this GET response, refundStatus describes the whole deposit: PARTIALLY_REFUNDED for a partial return and CONFIRMED for a full return. In the webhook, refundStatus describes the individual refund, so it is CONFIRMED in both cases. The GET response also includes refundedValue, refundedAt, refundEndToEndId, and retryable.

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:

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) 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.

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,
    "transactionDate": "2026-08-23T12:53:00.000Z",
    "occurredAt": "2026-08-25T09:14:22.104Z",
    "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 given by the payer to their bank.
payerNamestring | nullName of the payer who opened the contestation, when the provider sends it.
transactionDatestring | nullISO-8601 timestamp of when the contested transaction was made — not when the MED was opened.
occurredAtstringISO-8601 timestamp of this event.
walletChargeobject | nullThe 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.

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.

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',
  })
}

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_ACCEPTED costs you the money. REJECTED and CANCELED both leave the amount with you.