Cómo monitorear pagos USDT / USDC con Webhooks y RPC

Cree un receptor de pagos y un cursor de sondeo. Verifique contratos de tokens, destinatarios e importes enteros, deduplique eventos y concilie bloques faltantes o sustituidos.

Para monitorear pagos con stablecoins o detectar depósitos en un exchange, supervise las transferencias entrantes de USDT / USDC ERC-20 en cadenas EVM mediante Webhooks, logs de WebSocket o sondeo HTTP. Los desarrolladores y agentes de IA utilizan las mismas APIs; seleccione la cadena, el contrato del token, el destinatario y la profundidad de confirmación antes de procesar los pagos. Elija un flujo de depósitos, avisos de cobro para comercios o pagos salientes en la solución de monitoreo de transferencias USDT / USDC.

  • Primer paso: Cree una suscripción y supervise el destinatario, comenzando con una API key y su receptor HTTPS.
  • Se completa cuando: Una transferencia coincidente supera las comprobaciones de firma, cadena, token, destinatario e importe entero, se almacena una sola vez como candidata a pago y el receptor devuelve HTTP 204; verifíquela on-chain según su política de confirmaciones antes de acreditarla.

Flujos de pagos con stablecoins.

El monitoreo básico de transferencias está disponible. El filtrado de importes y tokens se ejecuta en su receptor. Próximamente habrá condiciones en el servidor, múltiples etapas de confirmación y alertas IM.

Para desarrolladores y agentes de IA: comience con una API key y su propio receptor HTTPS; filtre los contratos de tokens y los importes en su aplicación. Copie la configuración del webhook.

Tareas que esta guía le ayuda a completar

Elegir Webhook, WebSocket o sondeo

MétodoUsoRecuperación
WebhookActividad de direcciones enviada a su receptor HTTPS, incluidas las transferencias entrantes de tokensVerificar firmas, deduplicar IDs de eventos y gestionar subscription.gap / chain.reorg; reproducir coincidencias conservadas
WebSocketlogs filtrados mediante una conexión persistenteReconectar, volver a suscribirse y recuperar los bloques omitidos
Sondeo HTTPMonitoreo programado o recuperación de logs históricos con su propio cursorConsultar rangos eth_getLogs acotados y guardar el progreso

Lea ws y subscriptions en la respuesta pública de GET /v1/chains antes de elegir WebSocket. La compatibilidad de Push se comprueba por separado: consulte GET /v1/push/chains con su API key. Una cadena sin WebSocket puede utilizar Webhooks de direcciones si figura en esa lista. Use sondeo cuando necesite explorar bloques anteriores o trabajar sin una conexión persistente.

Recibir pagos con Webhooks

Crear una suscripción y supervisar el destinatario

Obtenga una API key y despliegue un receptor HTTPS en el puerto 443. Seleccione CHAIN en la lista autenticada de cadenas Push, establezca RECIPIENT como su dirección de depósito y RECEIVER_URL como la URL de su receptor. Este ejemplo de shell requiere jq; {} utiliza el número de confirmaciones predeterminado de la cadena. Revise min_confirmations, default_confirmations y max_confirmations antes de elegir un número diferente. La OpenAPI de Push define estas solicitudes.

set -eu
umask 077
: "${BLOCKVECTRA_API_KEY:?Set your API key}"
: "${CHAIN:?Select a chain from the Push chain list}"
: "${RECIPIENT:?Set the watched EVM recipient address}"
: "${RECEIVER_URL:?Set your HTTPS receiver URL}"
PUSH_URL='https://api.blockvectra.com/v1/push'

curl --fail-with-body -sS "$PUSH_URL/chains" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" > push-chains.json
jq -e --arg chain "$CHAIN" 'any(.chains[]; .chain == $chain)' push-chains.json
jq -n --arg url "$RECEIVER_URL" --arg chain "$CHAIN" \
  '{url: $url, chains: {($chain): {}}}' > create.json
curl --fail-with-body -sS "$PUSH_URL/subscriptions" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' -d @create.json > subscription.json

SUBSCRIPTION_ID=$(jq -er '.id' subscription.json)
jq -n --arg recipient "$RECIPIENT" '{addresses: [$recipient]}' > addresses.json
curl --fail-with-body -sS "$PUSH_URL/subscriptions/$SUBSCRIPTION_ID/addresses/add" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' -d @addresses.json > address-change.json
curl --fail-with-body -sS "$PUSH_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"

La creación devuelve id y secret. Guarde el secret de forma segura para el receptor; subscription.json contiene una credencial. Consulte la suscripción periódicamente hasta que applied_version >= change_version de address-change.json, y registre después chains[CHAIN].applied_from_block. Las nuevas direcciones generan coincidencias a partir de ese bloque, así que continúe el sondeo para cualquier intervalo de pagos anterior.

Verificar, deduplicar y validar pagos

Guarde la función de firma del body original como verify-push.js. El receptor siguiente acepta una Request de Web API en Node.js y lee sus bytes originales antes de analizar el JSON. Construya secrets como un Map que asocie cadenas de texto de IDs de suscripción con los secrets almacenados. Establezca una configuración expected de confianza como { chain, token, recipient, amountUnits }: token es el contrato de la stablecoin verificado en esa cadena y amountUnits es el importe entero positivo esperado en sus unidades mínimas. Compare los importes con BigInt, nunca con números de coma flotante ni con el símbolo del token.

import { verifyPush } from './verify-push.js';

export function selectPayment(data, event, expected) {
  if (data.chain !== expected.chain || event.type !== 'token.transfer' ||
      event.standard !== 'erc20') return null;
  const address = value => typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value);
  if (![event.token, event.to, expected.token, expected.recipient].every(address)) return null;
  if (event.token.toLowerCase() !== expected.token.toLowerCase() ||
      event.to.toLowerCase() !== expected.recipient.toLowerCase()) return null;
  const integer = value => typeof value === 'string' && /^[1-9][0-9]{0,77}$/.test(value);
  if (!integer(event.amount) || !integer(expected.amountUnits)) return null;
  const amount = BigInt(event.amount);
  if (amount >= (1n << 256n) || amount !== BigInt(expected.amountUnits)) return null;
  if (typeof event.id !== 'string' || typeof event.ref !== 'string' ||
      !/^0x[0-9a-f]{64}$/.test(event.tx_hash) ||
      !/^0x[0-9a-f]{64}$/.test(event.block_hash) ||
      !Number.isSafeInteger(event.log_index) || event.log_index < 0 ||
      !Number.isSafeInteger(event.block_number) || event.block_number < 0) return null;
  return {
    eventId: event.id, ref: event.ref, chain: data.chain,
    token: event.token, recipient: event.to, amountUnits: event.amount,
    txHash: event.tx_hash, logIndex: event.log_index,
    blockHash: event.block_hash, blockNumber: event.block_number,
  };
}

export async function receivePayments(request, expected, secrets, store) {
  const rawBody = Buffer.from(await request.arrayBuffer());
  const headers = Object.fromEntries(request.headers);
  if (!verifyPush(rawBody, headers, secrets)) return new Response(null, { status: 401 });
  let message;
  try { message = JSON.parse(rawBody.toString('utf8')); }
  catch { return new Response(null, { status: 400 }); }
  const data = message?.data;
  if (message?.type !== 'push.events' ||
      !Number.isSafeInteger(data?.subscription_id) || data.subscription_id <= 0 ||
      String(data.subscription_id) !== headers['bv-subscription-id'] ||
      data.chain !== expected.chain || !Array.isArray(data.events)) {
    return new Response(null, { status: 400 });
  }
  try {
    await store.transaction(async tx => {
      for (const event of data.events) {
        if (!event || typeof event.id !== 'string') continue;
        const recovery = event.type === 'subscription.gap' || event.type === 'chain.reorg';
        const payment = selectPayment(data, event, expected);
        if (!recovery && !payment) continue;
        if (!await tx.insertEventOnce(data.subscription_id, event)) continue;
        if (recovery) await tx.enqueueRecovery(data.chain, event);
        else await tx.recordPaymentCandidate(payment);
      }
    });
  } catch {
    return new Response(null, { status: 503 });
  }
  return new Response(null, { status: 204 });
}

Implemente store.transaction con almacenamiento duradero. Dentro de una transacción, insertEventOnce inserta un evento bajo una clave única (subscription_id, event.id) y devuelve false si está duplicado; confírmelo junto con recordPaymentCandidate o enqueueRecovery. Revierta todas las escrituras si se produce un fallo para que un reintento pueda procesar el evento. Los trabajos de recuperación también deben ser idempotentes. Devuelva 2xx en un máximo de 10 segundos solo después de confirmar la transacción; aplique el límite de body de 1 MiB en su servidor HTTP.

Este ejemplo comprueba un único importe de pago esperado. Para varios pedidos, busque la configuración de pago de confianza por cadena, token y destinatario, y concilie los pagos parciales o excesivos según sus propias reglas. Una candidata aún necesita verificación on-chain y su política de confirmaciones antes de acreditarse. Entre las suscripciones y el sondeo, concilie la misma transferencia por cadena, hash de transacción e índice del log para que dos vías de entrega no la acrediten dos veces; conserve el hash del bloque para rastrear bloques sustituidos.

Recuperar bloques faltantes o sustituidos

Para subscription.gap, encole una exploración desde from_block hasta to_block mediante el sondeo siguiente o los conjuntos de datos disponibles de la Data API. chain.reorg es un aviso gratuito de que los bloques entregados fueron sustituidos, no una laguna de entrega. Marque o descarte los eventos antiguos de ese rango por ref; concilie los registros de pagos por ref y tx_hash con la cadena canónica antes de procesar los eventos canónicos reenviados automáticamente con nuevos IDs. Deduplique esos eventos por id. El aviso de reorganización no avanza el progreso completado; registre complete_through_block por cadena y nunca deduzca la finalización a partir del mayor número de bloque de los eventos.

Replay acepta chain y from_block dentro del límite actual replayable_from_block. Solo reenvía las coincidencias conservadas; no explora períodos anteriores a la incorporación de la dirección o cadena ni períodos en que la suscripción estaba desconectada. Mantenga un cursor de sondeo para cubrir esos intervalos y las lagunas caducadas. Los fallos de solicitudes y los rangos de replay inválidos se describen en la referencia de errores; los cargos por entrega, historial y dirección-día se explican en las reglas de facturación.

Las secciones restantes implementan el filtrado de logs ERC-20 y el sondeo con cursor para el monitoreo y la recuperación.

Evento Transfer y parámetros de filtrado

Los contratos de tokens ERC-20 estándar emiten el siguiente evento en cada transferencia:

event Transfer(address indexed from, address indexed to, uint256 value);

Al llamar a eth_getLogs, pase la dirección del contrato del token y el array topics para filtrar los logs coincidentes:

ParámetroValorDescripción
addressDirección del contrato del token (o array de direcciones)Dirección del contrato de la stablecoin de destino. Puede especificar una sola dirección (p. ej., BSC USDT 0x55d398326f99059fF775485246999027B3197955, Base USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) o un array de direcciones para monitorear varios tokens simultáneamente
topics[0]0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3efHash de la firma del evento: keccak256("Transfer(address,address,uint256)")
topics[1]nullDirección del remitente (from). Como el monitoreo de depósitos acepta fondos de cualquier billetera de usuario, pase null para aceptar cualquier remitente
topics[2]Dirección del destinatario rellenada con ceros hasta 32 bytesDirección de destino (to). Según las especificaciones de logs EVM, los parámetros de dirección indexed ocupan 32 bytes (64 caracteres hexadecimales). Rellene a la izquierda la dirección del destinatario de 20 bytes con 12 bytes cero (24 caracteres hexadecimales cero) para formar un topic de 32 bytes.
fromBlockBloque inicial (hexadecimal)Inicio del rango de bloques de la consulta (incluido)
toBlockBloque final (hexadecimal)Fin del rango de bloques de la consulta (incluido)

El value no indexado (importe de la transferencia) se codifica en el campo data del objeto log como un uint256 hexadecimal de 32 bytes. Divida este importe bruto por 10^decimals para obtener el importe del token legible por humanos (p. ej., 18 decimales para BSC USDT; 6 decimales para USDC de Base y Ethereum).

Sondeo con cursor y límites de rango de bloques

Un servicio de sondeo consulta los nuevos bloques a intervalos regulares (como cada 3 a 5 segundos).

Avance del cursor

Mantenga un cursor persistente last_polled_block (el bloque más alto procesado y confirmado) en su base de datos:

  1. En cada ciclo de sondeo, establezca fromBlock = last_polled_block + 1.
  2. Consulte la cabecera actual de la cadena mediante eth_blockNumber y calcule la altura de destino segura safe_head según su profundidad de confirmación.
  3. Si fromBlock <= safe_head, consulte los logs por tramos hasta safe_head. Tras procesar correctamente cada tramo, avance el cursor.

Límite de rango de bloques

La amplitud de bloques de una llamada eth_getLogs se calcula como toBlock − fromBlock + 1. No debe superar el max_logs_block_range publicado para esa cadena en GET /v1/chains.

Si una solicitud supera ese rango, el servicio rechaza la llamada con el código de error -32602:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32602,
    "message": "eth_getLogs block range too large: max 1000 blocks",
    "data": {
      "reason": "logs_range_too_large",
      "docs_url": "https://docs.blockvectra.com/en/errors/#logs_range_too_large",
      "retryable": false
    }
  }
}

Las solicitudes que superan el rango de bloques devuelven el error JSON-RPC -32602 (no se facturan). En la lógica de su aplicación, lea max_logs_block_range de GET /v1/chains y acote cada tramo de sondeo: chunk_end = min(fromBlock + max_logs_block_range - 1, safe_head).

Gestionar reorganizaciones de bloques y profundidad de confirmación

Cerca de la cabecera de la blockchain pueden producirse reorganizaciones temporales de bloques (reorgs). Acreditar pagos en latest sin profundidad de confirmación conlleva el riesgo de acreditar transacciones de una rama huérfana que después se descarta.

Aplique las siguientes medidas para proteger el procesamiento de pagos:

Profundidad de confirmación

En lugar de consultar hasta latest, consulte hasta una altura de bloque de destino segura:

safe_head = current_head - CONFIRMATION_DEPTH

Establezca CONFIRMATION_DEPTH según la tolerancia al riesgo de su aplicación. Consultar solo hasta safe_head garantiza que se procesen únicamente bloques con suficientes confirmaciones.

Reorganizaciones durante el sondeo

El JSON-RPC EVM estándar establece removed: true en los objetos log solo en los flujos de suscripción a logs de WebSocket cuando un evento previamente emitido se revierte por una reorganización de la cadena. Al sondear por HTTP con eth_getLogs, las consultas devuelven logs de la cadena canónica; los logs reorganizados simplemente no aparecerán en consultas posteriores. Sondear dentro de safe_head garantiza que los pagos se procesen solo en bloques suficientemente confirmados.

Deduplicación por (transactionHash, logIndex)

Los receptores de pagos deben aplicar una idempotencia estricta:

  1. Varias transferencias en una transacción: Una sola transacción puede contener varios eventos Transfer hacia la misma dirección de depósito (por ejemplo, routers de tokens que dividen intercambios o contratos con múltiples pagos). Importante: transactionHash por sí solo no es único para cada pago.
  2. Sondeo superpuesto y reintentos: Cuando los servicios de sondeo se reinician, se recuperan de errores de red transitorios o retroceden varios bloques para gestionar reorganizaciones poco profundas, los logs del mismo rango de bloques se consultan varias veces.
  3. Unicidad del índice del log: logIndex identifica la posición relativa del log del evento dentro del bloque. Según las especificaciones EVM, el identificador único compuesto canónico de un evento es (transactionHash, logIndex).

En esquemas de bases de datos relacionales, declare un índice único compuesto en su tabla de registros de depósitos:

CREATE UNIQUE INDEX idx_transfers_tx_log ON deposit_records (transaction_hash, log_index);

Antes de procesar un depósito, compruebe las entradas (transactionHash, logIndex) existentes para garantizar que cada transferencia on-chain se acredite exactamente una vez.

Ejemplos de código completos

Los ejemplos siguientes muestran cómo obtener las capacidades de red de /v1/chains, calcular rangos de bloques seguros, sondear logs Transfer de stablecoins respetando los límites de rango y deduplicar eventos.

import { createPublicClient, formatUnits, http, parseAbiItem } from "viem";

const apiKey = process.env.BLOCKVECTRA_API_KEY;
if (!apiKey) {
  throw new Error("BLOCKVECTRA_API_KEY environment variable is not set");
}

const CHAIN = "bsc_mainnet";
const RPC_URL = "https://api.blockvectra.com/v1/bsc_mainnet";
const CHAINS_URL = "https://api.blockvectra.com/v1/chains";

// Target stablecoin contract address (BSC USDT used in this example)
const TOKEN_CONTRACT = "0x55d398326f99059fF775485246999027B3197955" as const;
const TOKEN_DECIMALS = 18;

// Monitored deposit address
const RECIPIENT_ADDRESS = "0xdded13D555B6DA811103cC1794D3d4330F69632C" as const;

// Confirmation depth to guard against chain reorgs
const CONFIRMATION_DEPTH = 15n;

// 1. Fetch chain capabilities from public metadata endpoint (unauthenticated, unbilled)
const chainsRes = await fetch(CHAINS_URL);
const { chains } = (await chainsRes.json()) as {
  chains: Array<{
    chain: string;
    ws: boolean;
    subscriptions: string[];
    max_logs_block_range: number;
  }>;
};

const chainConfig = chains.find((c) => c.chain === CHAIN);
if (!chainConfig) {
  throw new Error(`Chain ${CHAIN} not found in /v1/chains`);
}

const maxLogsRange = BigInt(chainConfig.max_logs_block_range || 1000);
console.log(`Chain: ${CHAIN} | WebSocket supported: ${chainConfig.ws} | Max logs range: ${maxLogsRange}`);

// 2. Initialize viem client with x-api-key header
const client = createPublicClient({
  transport: http(RPC_URL, {
    fetchOptions: {
      headers: { "x-api-key": apiKey },
    },
  }),
});

// Set to track processed events by composite key: (transactionHash, logIndex)
const processedLogs = new Set<string>();

// 3. Compute query range: subtract confirmation depth from current head
const currentHead = await client.getBlockNumber();
const safeHead = currentHead - CONFIRMATION_DEPTH;

// For demonstration, start cursor 10 blocks before safeHead
let cursor = safeHead > 10n ? safeHead - 10n : 0n;

console.log(`Current head: ${currentHead} | Safe head: ${safeHead} | Polling cursor: ${cursor}`);

while (cursor <= safeHead) {
  const chunkEnd = cursor + maxLogsRange - 1n < safeHead ? cursor + maxLogsRange - 1n : safeHead;

  const logs = await client.getLogs({
    address: TOKEN_CONTRACT,
    event: parseAbiItem(
      "event Transfer(address indexed from, address indexed to, uint256 value)"
    ),
    args: {
      to: RECIPIENT_ADDRESS,
    },
    fromBlock: cursor,
    toBlock: chunkEnd,
  });

  for (const log of logs) {
    const dedupKey = `${log.transactionHash}-${log.logIndex}`;
    if (processedLogs.has(dedupKey)) {
      continue;
    }
    processedLogs.add(dedupKey);

    const tokenAmount = formatUnits(log.args.value ?? 0n, TOKEN_DECIMALS);

    console.log(
      `[Payment Received] Amount: ${tokenAmount} | ` +
      `Tx: ${log.transactionHash} | Log: ${log.logIndex} | Block: ${log.blockNumber}`
    );
  }

  cursor = chunkEnd + 1n;
}

// Run with: npx tsx example.mts

Reglas de facturación y guías relacionadas

Próximos pasos

Última actualización:

En esta página