Cover Image for How to Detect Bitcoin Deposits Without Running a Full Node

How to Detect Bitcoin Deposits Without Running a Full Node

Bitcoin
BTC
Custodian
Exchange
Deposit Detection
UTXO
GraphQL API

A custodial Bitcoin product has the same data problem every other UTXO custodian has. The product needs to detect inbound deposits and outbound withdrawals against thousands of customer addresses, attribute every event to the right customer account, and stay in sync with the chain without operating bitcoind and an indexer fleet.

This article walks through the deposit reconciliation architecture used by production Bitcoin custodians on Bitquery. It covers the Bitcoin inputs and outputs query shape, the tip of chain heartbeat that drives the walker, the address-corpus batching pattern, the script-type filter for P2PKH versus SegWit versus P2SH wallets, the address book reconciliation step, the Coinpath screening pass for source-of-funds compliance, the finality buffer for safe deposit confirmation, and the portability of the same skeleton to Litecoin and Dogecoin.

It is the Bitcoin companion to the Cardano deposits guide. Same three-component architecture, different chain, different UTXO model details.

The Three Component Architecture

Every production Bitcoin deposit detector reduces to three components.

The first component is a tip of chain heartbeat. It fires on a fixed schedule and returns the current Bitcoin block height. This tells the rest of the pipeline how far the chain has advanced since the last sweep.

The second component is an address-corpus walker. It calls the Bitcoin inputs and outputs queries with height: {gteq: X, lteq: Y} filters and a batch of customer addresses, then pages through the results. Each iteration extends the walked range by a fixed chunk size until the cursor catches up to tip.

The third component is a reconciler. It joins the walker's output against the custodian's internal address book, attributes each transaction to a customer account, applies the confirmation buffer, and writes a deposit or withdrawal event to the ledger.

The three components run independently. The heartbeat decides when there is new work. The walker does the work. The reconciler turns the work into business state. Failures in one do not cascade into the others.

Tip of Chain Heartbeat

The heartbeat is one query that returns the current block height. Open the Bitcoin tip of chain query in the Bitquery GraphQL IDE to test it.

{
  bitcoin(network: bitcoin) {
    blocks(options: {limit: 1, desc: "height"}) {
      height
      timestamp {
        iso8601
      }
    }
  }
}

This is the cheapest query in the pipeline. Cache the response per second on your own side if the heartbeat fires more often than that. Bitcoin produces a block roughly every ten minutes, so a higher cadence buys nothing for freshness.

The heartbeat does two jobs. It tells the walker the upper bound of the next chunk to fetch. It also signals chain liveness, so a heartbeat that fails repeatedly is the earliest indicator that something upstream is wrong.

Address Corpus Walker

The walker is the workhorse query. It fetches every input and every output touching a batch of customer addresses inside a bounded block range. Open the Bitcoin deposit walker block range query in the Bitquery GraphQL IDE to run it.

{
  bitcoin(network: bitcoin) {
    inputs(
      inputAddress: {in: [
        "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h",
        "34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo",
        "bc1qgdjqv0av3q56jvd82tkdjpy7gdp9ut8tlqmgrpmv24sq90ecnvqqjwvw97"
      ]}
      height: {gteq: 950718, lteq: 951218}
      options: {asc: "block.height", limit: 1000, offset: 0}
    ) {
      block {
        height
        timestamp {
          iso8601
        }
      }
      transaction {
        hash
      }
      inputIndex
      inputAddress {
        address
      }
      outputTransaction {
        hash
      }
      value
      inputScriptType {
        type
      }
    }
    outputs(
      outputAddress: {in: [
        "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h",
        "34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo",
        "bc1qgdjqv0av3q56jvd82tkdjpy7gdp9ut8tlqmgrpmv24sq90ecnvqqjwvw97"
      ]}
      height: {gteq: 950718, lteq: 951218}
      options: {asc: "block.height", limit: 1000, offset: 0}
    ) {
      block {
        height
        timestamp {
          iso8601
        }
      }
      transaction {
        hash
      }
      outputIndex
      outputAddress {
        address
      }
      value
      outputScriptType {
        type
      }
    }
  }
}

The inputAddress: {in: [...]} and outputAddress: {in: [...]} filters take a batch of customer addresses, so one call covers many users. Smaller batches mean more requests. Larger batches risk hitting timeouts on busy block ranges or on wallets with high activity.

The height: {gteq: X, lteq: Y} filter bounds the work to a specific block range, which makes the call deterministic and idempotent. Re-running the same (addresses, height range) returns the same result every time. The reconciler can safely retry a chunk.

The asc: "block.height" ordering guarantees the walker sees events in chain order. Deposits and the spends that consume them arrive in the same order they appear on chain, which simplifies the reconciler's deduplication logic.

Script Type Filtering for P2PKH, SegWit, and P2SH

Bitcoin addresses come in three common formats, each tied to a different output script type. A custodian's address book often holds all three because legacy addresses are still in circulation, some inbound flows originate from older exchanges, and modern wallets default to SegWit.

The outputScriptType: {is: "pubkeyhash"} filter restricts the walker to P2PKH outputs (legacy addresses starting with 1). This is the right filter for an address corpus generated under the older derivation paths. Open the script-type filtering query in the Bitquery GraphQL IDE to run it.

{
  bitcoin(network: bitcoin) {
    outputs(
      outputAddress: {in: ["1FeexV6bAHb8ybZjqQMjJrcCrHGW9sb6uF"]}
      height: {gteq: 950718, lteq: 951218}
      outputScriptType: {is: "pubkeyhash"}
      options: {asc: "block.height", limit: 1000}
    ) {
      block { height }
      transaction { hash }
      outputIndex
      value
      outputScriptType { type }
    }
  }
}

For SegWit native addresses (bech32, starting with bc1q for P2WPKH or bc1p for P2TR Taproot), use outputScriptType: {is: "witness_v0_keyhash"} for P2WPKH or outputScriptType: {is: "witness_v1_taproot"} for Taproot. Dropping the filter altogether returns matching outputs regardless of script type, which works when the address corpus is a strict whitelist. For mixed corpora, run separate walker queries per script type and merge the results in the reconciler. This avoids the case where a script-type filter accidentally excludes a deposit the customer actually received.

For P2SH wallets (addresses starting with 3), use outputScriptType: {is: "scripthash"}. The same pattern works.

A custodian managing all three script types runs three walker queries per chunk, one per type, and writes the union to the reconciler. The total work is similar to running one unfiltered query, with the bonus that each script-type stream can be monitored separately.

To see what production-scale activity looks like at one address, point the walker at a real Bitcoin hot wallet over a recent 500-block window. The Binance wallet at bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h received 3,016 outputs totaling 53,071.48 BTC between block 950,718 and 951,218 (roughly three and a half days at the time of writing). Every one of those outputs returned outputScriptType.type of witness_v0_keyhash. A custodian polling a corpus of such wallets has to handle thousands of events per address per day at peak.

Chunk Size, Steady State, and Catch Up

The block range chunk size is the main lever for throughput. Bitcoin's block interval averages ten minutes, so a 100-block chunk covers roughly 17 hours of chain history and a 1,000-block chunk covers about a week. The right number depends on how active the address corpus is and how aggressive the timeout limits are.

Steady state runs at tip. The walker watches the heartbeat, and when a new block lands it fetches the chunk from lastWalkedHeight + 1 to tipHeight. Most of the time this chunk is small (one to a few blocks) and resolves in well under a second.

Catch up mode runs when the walker is more than one chunk behind tip. This happens after deployment, after a service restart that lost the cursor, or after extended downtime. The walker fetches chunk after chunk in sequence until the cursor reaches tip, at which point it falls back to steady state.

The catch up rate on Bitquery is bounded by query throughput. A custodian backfilling six months of history (roughly 26,000 Bitcoin blocks) can typically catch up in minutes to an hour depending on address corpus size. Syncing bitcoind from genesis against current chain size is a multi-day operation that occupies a full machine.

Address Book Reconciliation

The walker returns chain level data. Each output carries the recipient address, the transaction hash, the output index, and the value. The chain has no concept of which customer owns that address. That mapping lives inside the custodian's own systems. The reconciler is the component that joins the two: it takes each output the walker found and looks the recipient address up against the custodian's internal address book to attribute the deposit to a customer.

The reconciler holds two tables. The first is the address book, mapping every customer to the deposit addresses generated for them. The second is the ledger, the custodian's source of truth for deposits and withdrawals.

For every output returned by the walker, the reconciler looks up the recipient address in the address book, attributes the value to a customer, and writes a deposit event to the ledger keyed on the (transaction.hash, outputIndex) pair. For every input, it does the same, this time as a withdrawal event keyed on the (transaction.hash, inputIndex) pair.

The transaction hash plus index pair is the natural idempotency key. The walker is allowed to re run any chunk safely because the reconciler will deduplicate any event it has already seen.

A practical scaling note. The address book lookup is the hot path. Keep it in memory or in a low latency key value store. A custodian with 50,000 addresses needs sub millisecond lookups per output, not 10 millisecond round trips to a relational database.

Source of Funds Screening with Coinpath

A regulated custodian usually has a second job after attribution. Before the deposit clears to the customer's available balance, the back office wants to know where the funds came from. Did the sender's address belong to a sanctioned entity? Did the funds pass through a known mixer in the last few hops? Is the original source a darknet market?

The walker above answers "a deposit happened." The Bitcoin Coinpath API answers "where did it come from." The two queries run alongside each other.

The simplest screening query is an inbound trace at depth one. For every deposit that lands in the reconciler, fire a coinpath query keyed on the deposit's destination address and the transaction's date window. Open the Bitcoin Coinpath inbound trace query in the Bitquery GraphQL IDE to run it.

{
  bitcoin(network: bitcoin) {
    coinpath(
      receiver: {is: "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h"}
      date: {after: "2026-05-26"}
      options: {limit: 10, desc: "block.height"}
    ) {
      amount
      amount_usd: amount(in: USD)
      depth
      block {
        height
      }
      sender {
        address
        annotation
      }
      receiver {
        address
      }
      transaction {
        hash
      }
    }
  }
}

Run live, this query returned five recent depositors into a Binance hot wallet, ranging from a $18 dust deposit to a $22,236 deposit, each labelled with the exact sender address. Pipe sender.address through the in-house sanctions list and known-mixer registry. A hit pauses the deposit credit and routes the case to the compliance queue. A miss lets the reconciler proceed to the confirmation buffer.

For deeper provenance, add depth: {lteq: N} to trace the funds N hops back. A typical compliance configuration sets N to three or five, which covers the common "deposit → exchange → mixer → source" pattern.

{
  bitcoin(network: bitcoin) {
    coinpath(
      initialAddress: {is: "157EGyW4KNLnMFw9hxH5kVamUPfY9uBQbA"}
      depth: {lteq: 5}
      date: {after: "2026-01-01"}
      options: {limit: 100, asc: "block.height"}
    ) {
      depth
      block { height }
      sender { address annotation }
      receiver { address annotation }
      amount(in: USD)
      transaction { hash }
    }
  }
}

The annotation field on sender and receiver returns the human-readable label when Bitquery has identified the entity. A return value of Binance hot wallet, Tornado Cash, or Mt Gox cold storage flips the screening result without the custodian maintaining their own address-to-entity map.

The screening pass is per deposit, not per chunk. The walker batches addresses for throughput. The screening fan-outs one query per deposit because each deposit needs its own provenance trail. The pipeline should cap concurrent screening calls and queue the rest behind the confirmation buffer to keep API consumption predictable under bursts.

The screening result has to be persisted alongside the deposit event in the ledger. Auditors will ask "what did the screening report say at the moment the deposit cleared." Storing the coinpath response (or a hash of it) on the ledger row is the easiest way to answer that question six months later.

The Six Confirmation Buffer

Bitcoin transactions are not final the moment they appear in a block. A reorg can invalidate a recently mined block and roll back any deposit credited inside it. The standard mitigation is to delay deposit confirmation by a fixed number of blocks, conventionally six on Bitcoin (roughly an hour of wall clock time).

The walker can still fetch every block at tip and feed the reconciler. The reconciler holds events in a pending state until they clear the buffer, then promotes them to confirmed. The number of confirmations is a policy choice. Six is the long-standing community default for full settlement. Lower thresholds trade reorg risk for faster credit and are reserved for low-value flows or trusted counterparties.

The same buffer applies to withdrawals. A withdrawal that lands in a reorg loses its block but the transaction itself still exists in the mempool. The reconciler should treat unconfirmed withdrawals as in-flight, not as completed.

Bitquery vs Self Hosted bitcoind plus Indexer

The natural alternative is running bitcoind against an indexer such as Electrs, BTC RPC Explorer, or Esplora, then querying that indexer over RPC or HTTP. This is a legitimate choice for some teams. It is the wrong choice for most custodians.

Dimension Self hosted bitcoind + indexer Bitquery GraphQL
Time to first deposit detected Multi day initial node sync (700 GB+) Minutes (sign up, key, query)
Operating components bitcoind + indexer + storage layer One HTTP endpoint
Disk requirements 700 GB and growing for a pruned-and-indexed setup None
Catch up from history Re sync from genesis (days) Walk block ranges (minutes)
Address batch filter Build your own filter on top of raw chain data Built in via inputAddress: {in: [...]}
Script type decoding Parse output scripts yourself Built in via outputScriptType
High availability Customer builds and operates Built in
Multi UTXO chain coverage Separate node fleet per chain (BTC, LTC, DOGE, BCH) Same API, different network argument

The case for bitcoind plus a custom indexer is sub fifty millisecond latency on private queries, full control over the storage schema, and very large historical scans (multi year, full chain). The case against is everything else.

Operationally, running a Bitcoin node fleet for deposit detection means paying the cost of every Bitcoin Core release in resync time and migration work. Node downtime stops the deposit feed cold. None of these failure modes exist on Bitquery's hosted layer.

Multi UTXO Portability: The Same Loop on Litecoin and Dogecoin

The whole reconciliation pattern above ports to other UTXO chains without architectural changes. Swap network: bitcoin for network: litecoin or network: dogecoin, swap the address format in the corpus, and the same heartbeat plus walker plus reconciler runs against the new chain.

{
  bitcoin(network: litecoin) {
    outputs(
      outputAddress: {in: ["ltc1qyu50pytvc08yd5f2ycy4fd58nlmuhnwt4wwmyn"]}
      height: {gteq: 3113000, lteq: 3114000}
      options: {asc: "block.height", limit: 1000}
    ) {
      block { height }
      transaction { hash }
      value
      outputAddress { address }
    }
  }
}
{
  bitcoin(network: dogecoin) {
    outputs(
      outputAddress: {in: ["9wYP6KhQm8vwxgHsBn8GE62u7hmHwKm1oz"]}
      height: {gteq: 6220500, lteq: 6222000}
      options: {asc: "block.height", limit: 1000}
    ) {
      block { height }
      transaction { hash }
      value
      outputAddress { address }
    }
  }
}

Bitcoin Cash works the same way under network: bitcash. A custodian supporting BTC, LTC, DOGE, and BCH runs four instances of the loop, one per chain, against the same underlying Bitquery API. The reconciler shares the ledger schema across all chains. A new UTXO chain integration is a network-argument change, not a new infrastructure project.

The cross-chain numbers from the example windows above tell the story. The Bitcoin walker over a 500-block window (about three and a half days) returned 2,934 inputs and 3,016 outputs across the three Binance addresses, totaling 50,651 BTC sent and 53,071 BTC received. The Litecoin walker over a 1,000-block window (around a day and a half) returned 2,663 outputs totaling 52.5 million LTC at the single recipient address. The Dogecoin walker over a 1,500-block window returned 14 outputs totaling 3.89 billion DOGE at a P2SH cold-storage address, which is the canonical exchange-cold-storage signature: low event count, very high per-event value. Same loop. Same query shape. Different chain, different traffic profile.

Putting it Together

The full deposit detector loop is short.

every 30 to 60 seconds:
    tipHeight = heartbeat()

    if tipHeight - lastWalkedHeight > chunkSize:
        offset = 0
        while chunk not exhausted:
            results = walker(addressBatch, nextChunk, limit, offset)
            for each input, output in results:
                reconciler.apply(result, key=(transaction.hash, inputIndex or outputIndex))
            offset += limit

        # only advance the cursor once the reconciler has persisted the chunk
        if reconciler.confirmed(chunk):
            lastWalkedHeight = chunkEndHeight

    reconciler.promote_buffered_events(currentConfirmations=tipHeight - eventHeight)

The custodian's deposit and withdrawal feeds are now driven by Bitcoin chain state with no node fleet to operate.

This is the same architecture used by production Bitcoin custodians on Bitquery. It pairs with the Cardano deposits guide and the Avalanche balance polling guide to give a complete custodial data layer across UTXO and EVM chains.

Summary

A custodial Bitcoin product detecting deposits and withdrawals at scale has three real choices. Build and operate a Bitcoin node fleet and a custom indexer. Stream from a managed RPC provider and reconstruct UTXO movements per call. Or run a tip heartbeat plus an address-corpus walker against Bitquery and let the reconciler handle attribution.

The third option exists specifically because the deposit detection pattern is so universal across UTXO custodians, exchanges, and multi-chain wallets. The queries in this article are the canonical query shapes these products converge on. The same skeleton runs unchanged against Litecoin and Dogecoin.

Related Resources

Subscribe to our newsletter

Subscribe and never miss any updates related to our APIs, new developments & latest news etc. Our newsletter is sent once a week on Monday.