Forgedocs
Webhooks V2

TRANSFER

Overview

The TRANSFER webhook is sent when a PIX transfer initiated by your application is processed. This event indicates the result (success or failure) of a call to the /dict/pix endpoint.

When it is sent

  • PIX transfer processed successfully (LIQUIDATED)
  • PIX transfer failed (ERROR)

Payload Structure

{
  "type": "TRANSFER",
  "data": {
    "id": 456,
    "txId": null,
    "pixKey": "destino@email.com",
    "status": "LIQUIDATED",
    "payment": {
      "amount": "100.50",
      "currency": "BRL"
    },
    "refunds": [],
    "createdAt": "2024-01-15T10:30:00.000Z",
    "errorCode": null,
    "endToEndId": "E12345678901234567890123456789012",
    "ticketData": {},
    "webhookType": "TRANSFER",
    "debtorAccount": {
      "ispb": null,
      "name": null,
      "issuer": null,
      "number": null,
      "document": null,
      "accountType": null
    },
    "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
    "creditDebitType": "DEBIT",
    "creditorAccount": {
      "ispb": "18236120",
      "name": "NU PAGAMENTOS S.A.",
      "issuer": "260",
      "number": "12345-6",
      "document": "123.xxx.xxx-xx",
      "accountType": null
    },
    "localInstrument": "DICT",
    "transactionType": "PIX",
    "remittanceInformation": "Pagamento NF 12345"
  }
}

Important Fields

typestring

Always "TRANSFER" for sent PIX.

data.idnumber

Transaction ID. Same value returned by POST /dict/pix.

data.endToEndIdstring

End to End ID - unique identifier of the PIX transaction at the Central Bank.

data.statusstring

Transfer status:

  • LIQUIDATED: Transfer confirmed (success)
  • ERROR: Transfer failed
data.paymentobject

data.idempotencyKeystring

Idempotency key sent in the x-idempotency-key header of the original request.

data.creditorAccountobject

Data of who received (the recipient).

data.creditDebitTypestring

Always "DEBIT" for sent transfers.

data.errorCodestring

Error code when status === 'ERROR'. Can be null on success.

data.remittanceInformationstring

Transfer description (description field sent in the request).

Processing the Webhook

Node.js Example

interface TransferWebhook {
  type: 'TRANSFER';
  data: {
    id: number;
    status: 'LIQUIDATED' | 'ERROR';
    payment: {
      amount: string;
      currency: string;
    };
    endToEndId: string;
    idempotencyKey: string;
    creditorAccount: {
      name: string | null;
      document: string | null;
    };
    errorCode: string | null;
  };
}

async function handleTransfer(webhook: TransferWebhook) {
  const { data } = webhook;

  // Find transfer by idempotencyKey
  const transfer = await findTransferByIdempotencyKey(data.idempotencyKey);

  if (!transfer) {
    console.warn(`Transfer not found: ${data.idempotencyKey}`);
    return;
  }

  if (data.status === 'LIQUIDATED') {
    // Success - confirm transfer
    await updateTransfer(transfer.id, {
      status: 'COMPLETED',
      endToEndId: data.endToEndId,
      completedAt: new Date(),
    });

    // Notify user
    await notifyTransferSuccess({
      transferId: transfer.id,
      amount: parseFloat(data.payment.amount),
      recipient: data.creditorAccount.name,
    });

  } else if (data.status === 'ERROR') {
    // Failure - revert
    await updateTransfer(transfer.id, {
      status: 'FAILED',
      errorCode: data.errorCode,
    });

    // Notify user
    await notifyTransferFailed({
      transferId: transfer.id,
      errorCode: data.errorCode,
    });

    // Release blocked balance
    await releaseBlockedBalance(transfer.id);
  }
}

Python Example

from decimal import Decimal

def handle_transfer(webhook: dict):
    data = webhook['data']

    # Find transfer
    transfer = find_transfer_by_idempotency_key(data['idempotencyKey'])

    if not transfer:
        print(f"Transfer not found: {data['idempotencyKey']}")
        return

    if data['status'] == 'LIQUIDATED':
        # Success
        update_transfer(
            transfer_id=transfer.id,
            status='COMPLETED',
            e2e_id=data['endToEndId']
        )

        notify_transfer_success(
            transfer_id=transfer.id,
            amount=Decimal(data['payment']['amount']),
            recipient=data['creditorAccount'].get('name')
        )

    elif data['status'] == 'ERROR':
        # Failure
        update_transfer(
            transfer_id=transfer.id,
            status='FAILED',
            error_code=data['errorCode']
        )

        notify_transfer_failed(
            transfer_id=transfer.id,
            error_code=data['errorCode']
        )

        # Release balance
        release_blocked_balance(transfer.id)

Correlation with Request

Use idempotencyKey to correlate the webhook with your original request:

// 1. Create transfer
const idempotencyKey = crypto.randomUUID();
const transfer = await createTransfer(idempotencyKey, {
  pixKey: 'destino@email.com',
  amount: 100.50,
});

// 2. Save association
await saveTransfer({
  id: transfer.id,
  idempotencyKey,
  status: 'PENDING',
});

// 3. In the TRANSFER webhook
const savedTransfer = await findByIdempotencyKey(webhook.data.idempotencyKey);
// savedTransfer.id corresponds to the original transfer

Error Handling

Common error codes:

CodeDescriptionRecommended Action
INSUFFICIENT_BALANCEInsufficient balanceCheck balance before transferring
INVALID_KEYInvalid PIX keyVerify the key with the user
KEY_NOT_FOUNDKey not found in DICTRequest a valid key
ACCOUNT_BLOCKEDBlocked accountContact support
TIMEOUTProcessing timeoutTry again
if (data.status === 'ERROR') {
  switch (data.errorCode) {
    case 'INSUFFICIENT_BALANCE':
      // Notify insufficient balance
      await notifyInsufficientBalance(transfer);
      break;

    case 'INVALID_KEY':
    case 'KEY_NOT_FOUND':
      // Request new key from user
      await requestNewPixKey(transfer);
      break;

    case 'TIMEOUT':
      // Can try again with a new idempotency key
      await retryTransfer(transfer);
      break;

    default:
      // Generic error
      await notifyGenericError(transfer, data.errorCode);
  }
}

Balance Flow

sequenceDiagram
    participant App
    participant API
    participant Bank

    Note over App,Bank: Balance: available=1000, blocked=0

    App->>API: POST /dict/pix (R$ 100)
    API-->>App: { type: PENDING }

    Note over App,Bank: Balance: available=900, blocked=100

    alt Success
        Bank->>API: Confirmation
        API->>App: Webhook TRANSFER (LIQUIDATED)
        Note over App,Bank: Balance: available=900, blocked=0
    else Failure
        Bank->>API: Error
        API->>App: Webhook TRANSFER (ERROR)
        Note over App,Bank: Balance: available=1000, blocked=0
    end

Idempotency

Use data.id to avoid duplicate processing:

async function handleWebhook(webhook: TransferWebhook) {
  const webhookId = `transfer:${webhook.data.id}`;

  const isProcessed = await redis.sismember('processed', webhookId);
  if (isProcessed) {
    return; // Already processed
  }

  await redis.sadd('processed', webhookId);
  await handleTransfer(webhook);
}

Best Practices

Next Steps

On this page