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:
- 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. Hodle validates the endpoint and enables KYC notifications for your account before saving the webhook.
If KYC notifications cannot be enabled at that moment, the webhook is not saved; try again in a few minutes. Nothing else needs to be registered on your side.
Events
| Event | Description |
|---|---|
DEPOSIT_PIX_CONFIRMED | The PIX paying a deposit settled; the asset delivery has not finished yet. |
DEPOSIT_ASSET_SUCCESS | A deposit was completed successfully. |
DEPOSIT_ASSET_FAILED | A paid deposit could not deliver the requested asset. |
DEPOSIT_ASSET_REFUNDED | A partial or full PIX refund to the deposit's payer was confirmed. |
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. |
X-Hodle-Delivery-Attempt | 1 on the first delivery, then the number of the attempt on each retry. |
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. A webhook listens to one event: to receive another event on the
same URL, create another webhook for it, and it gets its own secret. Custom
headers you configure (for example Authorization) are sent on every delivery
of that webhook; a header left blank is not sent. 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
A response with a 2xx status code is considered successful. Any other status,
a network error, or no answer within 10 seconds is a failed attempt.
Deposit events follow the deposit event policy below. Every other event (payouts, KYC, disputes) is retried 3 times, on this schedule after the failed attempt before it:
| Attempt | X-Hodle-Delivery-Attempt | When |
|---|---|---|
| First delivery | 1 | As soon as the event happens. |
| Retry 1 | 2 | 10 seconds after attempt 1 failed. |
| Retry 2 | 3 | 60 seconds after attempt 2 failed. |
| Retry 3 | 4 | 5 minutes after attempt 3 failed. Last attempt. |
So if you answer 5xx, the next attempt arrives about 10 seconds later.
Each attempt carries the same body, signed again with a fresh
X-Hodle-Timestamp, so verify it like any other delivery. Every webhook
subscribed to the event is retried on its own schedule; one failing endpoint
does not delay the others. Deleting a webhook cancels its pending retries.
After the last retry the event is not sent again. Read the current state from
the API instead: GET /api/deposit/{id}
for deposits and
GET /api/wallet/payout/{transactionId}
for payouts.
Deposit status events
All deposit events — DEPOSIT_PIX_CONFIRMED, DEPOSIT_ASSET_SUCCESS,
DEPOSIT_ASSET_FAILED and DEPOSIT_ASSET_REFUNDED — are persisted with the
deposit and follow a stronger policy: the first delivery goes out right away,
then the event is retried every minute until your endpoint answers 2xx,
with no attempt limit. X-Hodle-Delivery-Attempt carries the attempt number.
Delivery is at least once: deduplicate by data.eventId, which stays the
same on every retry. All subscribed endpoints are attempted; if one fails, an
endpoint that already accepted the event may receive it again.
The events of one deposit go out in order: DEPOSIT_PIX_CONFIRMED first,
then DEPOSIT_ASSET_SUCCESS or DEPOSIT_ASSET_FAILED, then any
DEPOSIT_ASSET_REFUNDED. An event that keeps failing holds the later events of
the same deposit until your endpoint accepts it; other deposits are not held.
Retries only redeliver the notification: they never retry the deposit or send
another refund. Verify the HMAC signature on every
attempt and answer 2xx once you accepted the event, including an already
processed duplicate.
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.
externalIdis the idempotency key you sent when creating the payout, echoed back, so you can match the event to your own order without pollingGET /api/wallet/payout/{transactionId}.transactionId,trackId,asset,networkandtxHashidentify the same payout the GET endpoint reports.
Legacy bitcoin fields.
valueInSatoshisandquote.btcAmount/quote.satoshis/quote.btcToBrlRateare sent only when the payout asset is priced in bitcoin (a Lightning-funded payout). On a stablecoin payout they described nothing —fxRateAtTxis BRL per unit of the asset being sold, not per bitcoin — so they are absent. Usequote.assetAmountandquote.fxRateAtTxfor the asset that actually funded the payout, andvalueInBrl/feeas the source of truth for the money.invoiceis kept as an alias oftxHash: on a stablecoin payout it carries the on-chain transaction id, not a bolt11.
{
"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": "[email protected]",
"endToEndId": "E12345678202604281432abcdef123456",
"txHash": "2mZcnSeMEbYA8Rz6YNse6zkB7gsUcZsnvo5AhtgcF9DqHbGho3k9S16Y4Szd",
"receipt": {
"endToEndId": "E12345678202604281432abcdef123456",
"paidAt": "2026-04-28T14:32:11.708Z",
"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"
}
},
"receiptUrl": "https://receipts.hodle.com.br/receipts/65f1a8b2c3d4e5f6a7b8c9d0.pdf",
"quote": {
"brlAmount": "50.00",
"assetAmount": "9.65281000",
"fxRateAtTx": 5.17981
},
"invoice": "2mZcnSeMEbYA8Rz6YNse6zkB7gsUcZsnvo5AhtgcF9DqHbGho3k9S16Y4Szd"
}
}{
"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
| Field | Type | Description |
|---|---|---|
success | boolean | Always true on this event. |
transactionId | string | Our id for the payout — the same one POST /api/wallet/payout returned. |
externalId | string | null | The idempotency key you created the payout with. null when the payout was not created through the API. |
trackId | string | null | Internal tracking id of the payout. |
status | string | Always COMPLETED on this event. |
asset | string | null | Asset debited to fund the payout (USDT, USDC, BRLA, BRS, LIGHTNING). |
network | string | null | Network the asset was debited on. |
valueInBrl | string | Value paid out, in BRL. Source of truth for the amount. |
fee | string | Fee charged, in BRL. |
pixKey | string | null | The PIX key where BRL was sent. |
endToEndId | string | null | Bacen end-to-end id of the settled PIX. null if none was reported. |
txHash | string | null | On-chain transaction id of the debit, or the bolt11 invoice on a Lightning-funded payout. |
receipt | object | null | Receipt detail of the settled PIX — see Receipt detail. null when none was reported. |
receiptUrl | string | null | PDF comprovante, when one was rendered. |
quote.brlAmount | string | BRL amount quoted. |
quote.assetAmount | string | null | Amount in asset the BRL value converts to at fxRateAtTx. |
quote.fxRateAtTx | number | null | BRL per unit of asset at the time of the payout. |
quote.btcAmount | number | Bitcoin-priced payouts only. BTC amount. |
quote.satoshis | number | Bitcoin-priced payouts only. Amount in satoshis. |
quote.btcToBrlRate | number | Bitcoin-priced payouts only. BTC to BRL rate at the time. |
valueInSatoshis | number | Bitcoin-priced payouts only. Amount in satoshis. Absent on stablecoin payouts — use valueInBrl. |
invoice | string | null | Legacy 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.
| 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 settling institution. |
amountInBrl | string | Amount that left, in BRL. |
payerIspb | string | ISPB of the institution that debited the funds, read from the end-to-end id. |
brCode | string | Optional. Original PIX BR Code (copia e cola) for a QR payout, when available. |
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.pixKeyType | string | Optional. PIX key type, such as EMAIL or CPF, when available. |
receiver.bankName | string | Payee bank name when reported; otherwise null. |
receiver.bankCode | string | Optional. Bank code when reported, such as 001; distinct from the ISPB. |
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 (e.g. TRAN). |
Failed payout deliveries are retried 3 times (see the retry policy). 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 between institutions. 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 automatic refund is enabled on the account (ask support). 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": "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:
| 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 automatic refund is enabled on the account 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 — automatic refund is not enabled on the account, 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 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"
}
}When the subaccount was linked to an identity Hodle already verified,
KYC_APPROVED is sent right after POST /api/kyc, with the same fields plus linked: true.
The attemptId is the one that call returned:
{
"event": "KYC_APPROVED",
"data": {
"attemptId": "link_3f9c1e2a-...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"userId": "6a70b6364c38f215e99b8c8b",
"level": "level-1",
"approvedAt": "2026-09-30T12:00:00.000Z",
"linked": true
}
}When the second verification is enabled for your account,
subaccount KYC events also carry provider, naming which verification decided: PRIMARY for
the primary verification. Accounts without it never receive the field. With second-verification
webhooks also enabled, a second KYC_APPROVED (or KYC_REJECTED) arrives later for the same
attemptId with provider: "SECONDARY":
{
"event": "KYC_APPROVED",
"data": {
"attemptId": "att_a1b2c3...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"userId": "6a70b6364c38f215e99b8c8b",
"provider": "SECONDARY",
"kycUserId": "b8e2d3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"status": "APPROVED",
"approvedAt": "2026-05-08T22:40:02.000Z"
}
}Branch on provider before acting on a KYC event when it is present: the PRIMARY approval
is what unlocks transactions, and a SECONDARY rejection does not revoke it.
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"
}
}reason is a human-readable sentence you can show the customer. When it is present,
reasonCode is the stable code to branch on. reasonCode: "PROVIDER_REJECTED" is a final
rejection by the compliance screening (e.g. reason: "criminal lawsuits detected"): a new
attempt for that subaccount is refused, so do not offer a retry.
From the second verification (provider: "SECONDARY"),
reason is an object { code, retryable, retryFrom } and status is REJECTED or
REJECTED_RETRYABLE:
{
"event": "KYC_REJECTED",
"data": {
"attemptId": "att_a1b2c3...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"userId": "6a70b6364c38f215e99b8c8b",
"provider": "SECONDARY",
"kycUserId": "b8e2d3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"status": "REJECTED_RETRYABLE",
"reason": {
"code": "DOCUMENT_UNREADABLE",
"retryable": true,
"retryFrom": "documents"
}
}
}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"
}
}When the attempt ran through the second verification partner, error is a stable code and
the payload also carries retryable and retryFrom. A retryable failure is fixed by a new
attempt: with retryFrom: "documents", upload a new identity document and a new
SELFIE-FROM-LIVENESS, then call POST /api/kyc again. The documents of the failed attempt
are deleted, so their ids are not accepted twice.
{
"event": "KYC_FAILED",
"data": {
"attemptId": "5f4e3d2c-1b0a-4f9e-8d7c-6b5a4f3e2d1c",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"userId": "6a70b6364c38f215e99b8c8b",
"error": "LIVENESS_BELOW_THRESHOLD",
"retryable": true,
"retryFrom": "documents"
}
}error | retryable | What to do |
|---|---|---|
LIVENESS_BELOW_THRESHOLD | true | The selfie did not score high enough. Record a new liveness selfie (good light on the face, no glasses or hat) and resubmit. |
DEPOSIT_ASSET_SUCCESS
Sent when a deposit completes: the asset was delivered to the requested address.
It follows the deposit event policy, so it always
arrives after that deposit's DEPOSIT_PIX_CONFIRMED.
{
"event": "DEPOSIT_ASSET_SUCCESS",
"data": {
"eventId": "deposit-completed-66f0c1e2a4b5c6d7e8f90123",
"occurredAt": "2026-09-22T23:52:40.000Z",
"success": true,
"externalId": "my-order-123",
"walletCharge": "66f0c1e2a4b5c6d7e8f90123",
"trackId": "trk_abc123",
"asset": "USDC",
"network": "solana",
"value": 5000,
"valueInBrl": "50.00",
"valueInCurrency": 9.21,
"fee": "1.00",
"fxRateAtTx": 5.32
}
}Fields
| Field | Type | Description |
|---|---|---|
eventId | string | Stable id of this notification, one per deposit. The same on every retry; use it to deduplicate. |
occurredAt | string | ISO-8601 time the deposit was completed, not the HTTP delivery time. |
success | boolean | Always true on this event. |
externalId | string | The external ID sent in the deposit request, or an auto-generated UUID. |
walletCharge | string | Hodle id of the deposit, the same one the POST returned. |
trackId | string | Hodle tracking id of the deposit. |
asset, network | string | Asset and network delivered. |
value | number | Amount in BRL cents. |
valueInBrl | string | Same amount, formatted BRL. |
valueInCurrency | number | Stablecoin deposits: amount delivered, in units of asset. |
fee | string | Stablecoin deposits: fee charged, formatted BRL. |
fxRateAtTx | number | Stablecoin deposits: BRL per unit of asset at the time of the deposit. |
eventId, occurredAt, success, externalId, walletCharge, trackId,
asset, network, value and valueInBrl are present on every deposit.
The last three rows are sent on stablecoin deposits; other assets may carry
extra fields that are not part of the contract, so do not depend on them.
Treat DEPOSIT_ASSET_SUCCESS as authoritative for delivery. The on-chain
transaction hash and the amount delivered in the asset's smallest unit are
available from GET /api/deposit/{id}.
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.
{
"event": "DEPOSIT_ASSET_SUCCESS",
"data": {
"success": true,
"asset": "USDT",
"network": "polygon",
"externalId": "my-order-123",
"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"
}
}| Field | Type | Description |
|---|---|---|
payer.name | string | null | Name of whoever paid the PIX, as the bank reports it. |
payer.taxId | string | null | CPF/CNPJ of the payer, digits only. |
endToEndId | string | null | End-to-end id of the PIX that funded the deposit. |
paidAt | string | null | ISO-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/{id}.
DEPOSIT_PIX_CONFIRMED
Sent as soon as the PIX paying a deposit is settled in the SPI, before the
BRL is converted and before the asset is sent. On an on-chain delivery (for
example USDC on Solana) it typically arrives about a minute before
DEPOSIT_ASSET_SUCCESS: that minute is the conversion and the on-chain
delivery, which DEPOSIT_ASSET_SUCCESS waits for. The event is dispatched as
soon as the settlement reaches Hodle, usually within seconds of the PIX.
Use it to release your customer's order as soon as they paid, without waiting for the asset to land.
{
"event": "DEPOSIT_PIX_CONFIRMED",
"data": {
"status": "FIAT_PAID",
"endToEndId": "E18236120202609222351s0000000001",
"paidAt": "2026-09-22T23:51:43.000Z",
"eventId": "deposit-pix-confirmed-66f0c1e2a4b5c6d7e8f90123",
"occurredAt": "2026-09-22T23:51:43.000Z",
"externalId": "my-order-123",
"walletCharge": "66f0c1e2a4b5c6d7e8f90123",
"trackId": "trk_abc123",
"asset": "USDC",
"network": "solana",
"value": 5000,
"valueInBrl": "50.00"
}
}| Field | Type | Description |
|---|---|---|
status | string | Always FIAT_PAID: the PIX settled, the asset is not delivered yet. |
endToEndId | string | null | End-to-end id of the PIX that paid the deposit. |
paidAt | string | ISO-8601 time the PIX settled, as reported by the settling institution, so the minute of endToEndId matches it. When the institution does not report it, the time Hodle received the settlement confirmation. The same value the GET returns. |
eventId | string | Stable id of this notification, one per deposit. Use it to deduplicate retries. |
occurredAt | string | Same instant as paidAt, not the HTTP delivery time. |
externalId, walletCharge, trackId | string | Ids of the deposit, as in the other deposit events. |
asset, network | string | Asset and network requested in the deposit. |
value, valueInBrl | number, string | Deposit amount, in BRL cents and formatted BRL. |
What it guarantees. When this event is sent, the PIX is final on our side:
the money is in the account and the deposit is committed to the requested
asset. It is not a promise that the asset will land. If the conversion or the
delivery then fails definitively, you receive
DEPOSIT_ASSET_FAILED; Hodle returns the PIX to the
payer — you do not need to request it — and
DEPOSIT_ASSET_REFUNDED confirms it once the
return settles. Transient failures, and deliveries whose on-chain outcome is
still being confirmed, keep the deposit PROCESSING while Hodle reconciles
them. The deposit is never credited as BRL in your balance instead.
Subscribing. Create a webhook for it in API Keys → Webhooks (Pix do depósito recebido); it has its own signing secret, like every webhook. The event is generated for deposits paid while the subscription exists, and is sent for every deposit paid by PIX.
Order. It follows the deposit event policy: it
is always delivered before DEPOSIT_ASSET_SUCCESS, DEPOSIT_ASSET_FAILED and
DEPOSIT_ASSET_REFUNDED of the same deposit, and while it keeps failing on
your endpoint those later events wait for it. Still, make your handler
tolerant: never move an order backwards because of an older event.
Available only in production. Sandbox does not send webhooks to external
URLs. In sandbox, poll GET /api/deposit/{id}
after settling a charge.
Deposit failure and refund
Subscribe separately to DEPOSIT_ASSET_FAILED and DEPOSIT_ASSET_REFUNDED in
API Keys → Webhooks. Receiving the PIX does not mean the requested asset was
delivered.
- Who returns the money. When a paid deposit fails definitively, Hodle
returns the PIX to the payer. You do not need to request it. The return is
automatic when the deposit fails before the BRL is converted; after
conversion, Hodle's operations team reviews the failure and sends the
return, so there is no fixed deadline.
DEPOSIT_ASSET_REFUNDEDis sent once the return settles. - Coverage.
DEPOSIT_ASSET_REFUNDEDis sent for every confirmed PIX return of a deposit.DEPOSIT_ASSET_FAILEDis sent for stablecoin, Lightning and bitcoin deliveries, including USDC and USDT on Solana. A deposit that fails without the event is still reported asFAILEDbyGET /api/deposit/{id}. - How long
PROCESSINGlasts. Most deliveries finish within a few minutes. A delivery whose on-chain outcome is not yet known staysPROCESSINGuntil Hodle confirms it — never markedFAILEDwhile the asset may still land — and then becomesCOMPLETEDorFAILED.
Both events include these fields in data, alongside their event-specific fields:
| Field | Type | Description |
|---|---|---|
eventId | string | Stable notification id. Treat it as opaque and use it to deduplicate retries. |
occurredAt | string | ISO-8601 time of the failure or confirmed refund, not the HTTP delivery time. |
externalId | string | External id of the original deposit, used to match your order. |
walletCharge | string | Hodle id of the original deposit charge. |
trackId | string | Tracking id of the deposit; may be absent if none was recorded. |
asset | string | Asset requested in the deposit, such as LIGHTNING or USDT. A PIX refund returns BRL to the payer. |
network | string | Network requested in the deposit, such as lightning or polygon. |
value | number | Original deposit amount in BRL cents: 2000 means R$20.00. |
valueInBrl | string | Original 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.
{
"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
| Field | Type | Description |
|---|---|---|
success | boolean | Always false: delivery failed. |
status | string | Always FAILED. |
errorCode | string | DEPOSIT_DELIVERY_FAILED. |
errorDescription | string | Customer-safe explanation. Raw provider errors are not included. |
retryable | boolean | Always 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 confirmed. It
reports both partial and full refunds, whatever caused them: a delivery
failure, a refund made by Hodle's team, or a refund of a deposit that had
already been COMPLETED. It can therefore arrive without a prior
DEPOSIT_ASSET_FAILED. The event only reports a refund; it never starts one.
Full refund
In this example the original R$20.00 deposit was fully returned to the payer:
{
"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:
{
"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
| Field | Type | Description |
|---|---|---|
success | boolean | Always true: this refund was confirmed. It does not mean the requested asset was delivered or the entire deposit was refunded. |
status | string | REFUNDED when cumulative confirmed refunds equal the original deposit amount. Otherwise, the charge's previous status. |
refundStatus | string | Always CONFIRMED for the individual refund reported by this event, including a partial refund. |
refundValue | number | Amount of this individual refund in BRL cents. |
refundedValue | number | Cumulative confirmed refunds for the deposit in BRL cents at verification time. |
refundEndToEndId | string | PIX end-to-end id of this refund. It identifies the return, not the original payment. |
partial | boolean | true while cumulative confirmed refunds are less than value; false once the entire deposit has been returned. |
retryable | boolean | true 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/{id}
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:
| 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.
The event reaches the account that created the deposit the contested Pix paid
for. It is linked to your order by walletCharge.externalId, and to the Pix by
endToEndId.
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,
"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
| 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. Only on your own virtual account; null otherwise. |
payer | object | Only on your own virtual account: name and taxId (CPF/CNPJ, digits only) of the payer. Absent otherwise. |
account | object | Only on your own virtual account: name, taxId and accountId of your account that received the Pix. Absent otherwise. |
transactionDate | string | null | ISO-8601 timestamp of when the contested transaction was made — not when the MED was opened. |
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.
Payout from Your Own Wallet
Pay a PIX from BRS held in a Solana wallet you control, such as a Privy embedded wallet. Hodle prepares the debit, your user signs it, Hodle pays the PIX.
Hodle for AI agents
How an AI agent builds on the Hodle Platform API — read/write split, sandbox-first posture, and where to start.