Comment surveiller les paiements USDT / USDC avec les Webhooks et le RPC

Construisez un récepteur de paiement et un curseur de polling. Vérifiez les contrats de jetons, les destinataires et les montants entiers, dédupliquez les événements et réconciliez les blocs manquants ou réorganisés.

Pour la surveillance des paiements en stablecoins ou la détection des dépôts sur les plateformes d'échange, surveillez les transferts entrants ERC-20 USDT / USDC sur les chaînes EVM à l'aide de Webhooks, de logs WebSocket ou du polling HTTP. Les développeurs et les agents IA utilisent les mêmes API ; sélectionnez la chaîne, le contrat de jeton, le destinataire et la profondeur de confirmation avant de traiter les paiements. Choisissez un flux de travail de dépôt, de notification commerçant ou de paiement sortant dans la solution de surveillance des transferts USDT / USDC.

  • Première étape : Créer un abonnement et surveiller le destinataire, en commençant par une API key et votre récepteur HTTPS.
  • Terminé quand : Un transfert correspondant passe avec succès les vérifications de signature, chaîne, jeton, destinataire et montant entier, est enregistré une seule fois comme candidat au paiement, et le récepteur renvoie HTTP 204 ; vérifiez-le on-chain selon votre politique de confirmation avant de le créditer.

Flux de paiement en stablecoins.

La surveillance basique des transferts est disponible. Le filtrage des montants et des jetons s'exécute dans votre récepteur. Les conditions côté serveur, les étapes de confirmation multiples et les alertes par messagerie instantanée seront bientôt disponibles.

Pour les développeurs et les agents IA : commencez avec une API key et votre propre récepteur HTTPS ; filtrez les contrats de jetons et les montants dans votre application. Copier la configuration du webhook.

Tâches que ce guide vous aide à accomplir

Choisir entre Webhook, WebSocket ou polling

MéthodeCas d'usageRécupération
WebhookActivité d'adresses envoyée à votre récepteur HTTPS, y compris les transferts de jetons entrantsVérifier les signatures, dédupliquer les ID d'événements et gérer subscription.gap / chain.reorg ; rejouer les correspondances conservées
WebSocketlogs filtrés sur une connexion persistanteSe reconnecter, se réabonner et rattraper les blocs manqués
Polling HTTPSurveillance planifiée ou rattrapage de logs historiques avec votre propre curseurInterroger des plages eth_getLogs bornées et persister la progression

Lisez ws et subscriptions dans la réponse publique GET /v1/chains avant de choisir WebSocket. La prise en charge de Push fait l'objet d'une vérification distincte : lisez GET /v1/push/chains avec votre API key. Une chaîne sans WebSocket peut utiliser les Webhooks d'adresses si elle y est répertoriée. Utilisez le polling lorsque vous devez analyser des blocs antérieurs ou fonctionner sans connexion persistante.

Recevoir des paiements avec des Webhooks

Créer un abonnement et surveiller le destinataire

Obtenez une API key et déployez un récepteur HTTPS sur le port 443. Sélectionnez CHAIN dans la liste authentifiée des chaînes Push, définissez RECIPIENT sur votre adresse de dépôt et RECEIVER_URL sur l'URL de votre récepteur. Cet exemple shell nécessite jq ; {} utilise le nombre de confirmations par défaut de la chaîne. Inspectez min_confirmations, default_confirmations et max_confirmations avant de choisir un nombre différent. L'OpenAPI Push définit ces requêtes.

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 création renvoie id et secret. Conservez le secret en toute sécurité pour le récepteur ; subscription.json contient un identifiant d'authentification. Interrogez l'abonnement par polling jusqu'à ce que applied_version >= change_version de address-change.json, puis enregistrez chains[CHAIN].applied_from_block. Les nouvelles adresses correspondent à partir de ce bloc, continuez donc le polling pour tout intervalle de paiement antérieur.

Vérifier, dédupliquer et valider les paiements

Enregistrez la fonction de vérification de signature sur corps brut sous le nom verify-push.js. Le récepteur ci-dessous accepte une Request Web API dans Node.js et lit ses octets d'origine avant l'analyse JSON. Construisez secrets sous forme de Map associant les chaînes d'ID d'abonnement aux secrets enregistrés. Définissez la configuration de confiance expected sur { chain, token, recipient, amountUnits } : token est le contrat de stablecoin vérifié sur cette chaîne et amountUnits est le montant entier positif attendu dans ses plus petites unités. Comparez les montants avec BigInt, jamais avec des nombres à virgule flottante ou le symbole du jeton.

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 });
}

Implémentez store.transaction avec un stockage durable. Au sein d'une même transaction, insertEventOnce insère un événement sous une clé unique (subscription_id, event.id) et renvoie false en cas de doublon ; validez-le (commit) conjointement avec recordPaymentCandidate ou enqueueRecovery. Annulez (roll back) toutes les écritures en cas d'échec afin qu'une nouvelle tentative puisse traiter l'événement. Les tâches de récupération doivent également être idempotentes. Ne renvoyez 2xx dans un délai de 10 secondes qu'après validation ; appliquez la limite de taille de corps de 1 MiB sur votre serveur HTTP.

Cet exemple vérifie un seul montant de paiement attendu. Pour plusieurs commandes, recherchez la configuration de paiement de confiance par chaîne, jeton et destinataire, et réconciliez les paiements partiels ou excédentaires selon vos propres règles. Un candidat nécessite encore une vérification on-chain et votre politique de confirmation avant d'être crédité. Entre les abonnements et le polling, réconciliez le même transfert par chaîne, hash de transaction et index de log afin que deux chemins de distribution ne le créditent pas deux fois ; conservez le hash de bloc pour suivre les blocs remplacés.

Récupérer les blocs manquants ou réorganisés

Pour subscription.gap, mettez en file d'attente une analyse de from_block jusqu'à to_block en utilisant le chemin de polling ci-dessous ou les jeux de données disponibles de la Data API. chain.reorg est une notification gratuite indiquant que des blocs livrés ont été remplacés, et non une lacune de distribution. Marquez ou rejetez les anciens événements dans cette plage par ref ; réconciliez les enregistrements de paiement par ref et tx_hash par rapport à la chaîne canonique avant de traiter les événements canoniques automatiquement redistribués avec de nouveaux identifiants. Dédupliquez ces événements par id. La notification de réorganisation n'avance pas la progression achevée ; enregistrez complete_through_block par chaîne, ne déduisez jamais l'achèvement à partir du plus grand numéro de bloc d'événement.

Le rejeu (replay) accepte chain et from_block à l'intérieur de la limite actuelle replayable_from_block. Il renvoie uniquement les correspondances conservées ; il n'analyse pas les périodes antérieures à l'ajout de l'adresse ou de la chaîne, ni les périodes où l'abonnement était hors ligne. Conservez un curseur de polling pour couvrir ces intervalles et les lacunes expirées. Les échecs de requête et les plages de rejeu non valides sont traités dans la référence des erreurs ; les frais de distribution, d'historique et de jours-adresses sont expliqués dans les règles de facturation.

Les sections suivantes implémentent le filtrage des logs ERC-20 et le polling basé sur curseur pour la surveillance et la récupération.

Événement Transfer et paramètres de filtre

Les contrats de jetons ERC-20 standard émettent l'événement suivant à chaque transfert :

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

Lors de l'appel à eth_getLogs, transmettez l'adresse du contrat de jeton et le tableau topics pour filtrer les logs correspondants :

ParamètreValeurDescription
addressAdresse du contrat de jeton (ou tableau d'adresses)Adresse du contrat de stablecoin cible. Vous pouvez spécifier une adresse unique (ex. BSC USDT 0x55d398326f99059fF775485246999027B3197955, Base USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913), ou un tableau d'adresses pour surveiller plusieurs jetons simultanément
topics[0]0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3efHash de la signature de l'événement : keccak256("Transfer(address,address,uint256)")
topics[1]nullAdresse de l'expéditeur (from). Comme la surveillance des dépôts accepte les fonds provenant de n'importe quel portefeuille d'utilisateur, transmettez null pour faire correspondre n'importe quel expéditeur
topics[2]Adresse du destinataire sur 32 octets complétée par des zérosAdresse de destination (to). Selon les spécifications des logs EVM, les paramètres d'adresse indexed occupent 32 octets (64 caractères hexadécimaux). Complétez à gauche l'adresse de destinataire de 20 octets avec 12 octets nuls (24 zéros hexadécimaux) pour former un topic de 32 octets.
fromBlockBloc de début (hexadécimal)Début de la plage de blocs de la requête (inclus)
toBlockBloc de fin (hexadécimal)Fin de la plage de blocs de la requête (inclus)

La value non indexée (montant du transfert) est encodée dans le champ data de l'objet de log sous forme d'un uint256 hexadécimal de 32 octets. Divisez ce montant brut par 10^décimales pour obtenir le montant de jetons lisible par l'humain (par exemple 18 décimales pour BSC USDT ; 6 décimales pour Base et Ethereum USDC).

Polling par curseur et limites de plage de blocs

Un service de polling interroge les nouveaux blocs à intervalles réguliers (par exemple toutes les 3 à 5 secondes).

Avancement du curseur

Maintenez un curseur persistant last_polled_block (le bloc le plus élevé traité et validé) dans votre base de données :

  1. Pour chaque cycle de polling, définissez fromBlock = last_polled_block + 1.
  2. Interrogez la tête de chaîne actuelle via eth_blockNumber, et calculez la hauteur cible sécurisée safe_head en fonction de votre profondeur de confirmation.
  3. Si fromBlock <= safe_head, interrogez les logs par morceaux (chunks) jusqu'à safe_head. Après avoir traité chaque morceau avec succès, avancez le curseur.

Limite de plage de blocs

L'étendue de blocs d'un appel eth_getLogs unique est calculée par toBlock − fromBlock + 1. Elle ne doit pas dépasser la valeur max_logs_block_range publiée pour cette chaîne dans GET /v1/chains.

Si une requête dépasse cette plage, le service rejette l'appel avec le code d'erreur -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
    }
  }
}

Les requêtes dépassant la plage de blocs renvoient l'erreur JSON-RPC -32602 (non facturée). Dans la logique de votre application, lisez max_logs_block_range depuis GET /v1/chains et bornez chaque tranche de polling : chunk_end = min(fromBlock + max_logs_block_range - 1, safe_head).

Gestion des réorganisations de blocs et profondeur de confirmation

Près du sommet de la blockchain, des réorganisations temporaires de blocs (reorgs) peuvent se produire. Créditer des paiements au niveau de latest sans profondeur de confirmation risque de créditer des transactions sur une branche orpheline qui sera ultérieurement rejetée.

Appliquez les mesures de protection suivantes pour sécuriser le traitement des paiements :

Profondeur de confirmation

Au lieu d'interroger jusqu'à latest, interrogez jusqu'à une hauteur de bloc cible sécurisée :

safe_head = current_head - CONFIRMATION_DEPTH

Définissez CONFIRMATION_DEPTH en fonction de la tolérance au risque de votre application. N'interroger que jusqu'à safe_head garantit que seuls les blocs disposant d'un nombre suffisant de confirmations sont traités.

Réorganisations pendant le polling

Le JSON-RPC EVM standard définit removed: true sur les objets de log uniquement dans les flux d'abonnements WebSocket de logs lorsqu'un événement émis précédemment est annulé suite à une réorganisation de la chaîne. Lors d'un polling HTTP avec eth_getLogs, les requêtes renvoient les logs de la chaîne canonique ; les logs réorganisés n'apparaîtront tout simplement pas dans les requêtes ultérieures. Le polling en deçà de safe_head garantit que les paiements ne sont traités que sur des blocs suffisamment confirmés.

Déduplication par (transactionHash, logIndex)

Les écouteurs de paiement doivent imposer une idempotence stricte :

  1. Transferts multiples dans une seule transaction : une transaction unique peut contenir plusieurs événements Transfer vers la même adresse de dépôt (par exemple, des routeurs de jetons divisant des swaps ou des contrats de paiements multiples). Important : transactionHash seul n'est pas unique par paiement.
  2. Chevauchement du polling et nouvelles tentatives : lorsque les services de polling redémarrent, récupèrent d'erreurs réseau temporaires ou reculent de plusieurs blocs pour gérer des réorganisations peu profondes, les logs de la même plage de blocs sont interrogés plusieurs fois.
  3. Unicité de l'index de log : le logIndex identifie la position relative du log d'événement dans le bloc. Selon les spécifications EVM, l'identifiant composite canonique unique pour un événement est (transactionHash, logIndex).

Dans les schémas de base de données relationnelle, déclarez un index unique composite sur votre table d'enregistrements de dépôts :

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

Avant de traiter un dépôt, vérifiez la présence d'entrées existantes (transactionHash, logIndex) afin de garantir que chaque transfert on-chain n'est crédité qu'une seule et unique fois.

Exemples de code complets

Les exemples ci-dessous illustrent la récupération des capacités réseau depuis /v1/chains, le calcul des plages de blocs sécurisées, le polling des logs Transfer de stablecoins dans le respect des limites de plage et la déduplication des événements.

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

Règles de facturation et guides associés

Étapes suivantes

Dernière mise à jour :

Sur cette page