Skip to content

Monitoring Transaction Status⚓︎

BEGINNER

After announcing a transaction to the NEM network, it remains unconfirmed until it is included in a block.

Monitoring status changes is essential for building responsive applications that can react to transaction confirmation or failure.

This tutorial shows how to poll a transaction's status until it is confirmed, how to check whether it is still waiting in the unconfirmed pool, and how to decide when it will never confirm.

This kind of monitoring typically happens right after announcing a transaction, as shown in the Transfer XEM tutorial, to make sure it gets confirmed.

Polling is not recommended for production

This tutorial uses polling to check the transaction status for illustration purposes, but it is not the recommended approach for production applications.

WebSockets provide a more responsive solution without the overhead of repeated API calls.

Prerequisites⚓︎

This tutorial uses the NEM REST API without requiring an SDK. You only need a way to make HTTP requests.

Full Code⚓︎

import json
import os
import time
import urllib.error
import urllib.request

NODE_URL = os.getenv("NODE_URL", "http://libertalia.nemtest.net:7890")
print(f'Using node {NODE_URL}')


# Transaction hash to monitor
transaction_hash = os.getenv(
    "TRANSACTION_HASH",
    "AE0B2142DFB75C9C126442EF612944E926BCE63FA34B353CDE409E2E87703C0B")
# Signer's address
signer_address = os.getenv(
    "SIGNER_ADDRESS",
    "TBONKWCOWBZYZB2I5JD3LSDBQVBYHB757VN3SKPP")
# Transaction signature
transaction_signature = os.getenv(
    "TRANSACTION_SIGNATURE",
    "99B1850FADDB964112D030AA0A5C9F8B5B1B6B992B407D9C70F52F089BD651DF"
    "A7D4991639A48B810EFD98C45060D7AD9AE57FDA37F58561459DCE8D0A747F02")

print(f"Monitoring transaction: {transaction_hash}")


def get_confirmation_height(tx_hash):
    """
    Query /transaction/get once to check for confirmation.

    Args:
        tx_hash: hash of the transaction to check

    Returns:
        The height of the block containing the transaction, or None
        if the transaction is not confirmed yet
    """
    url = f"{NODE_URL}/transaction/get?hash={tx_hash}"
    try:
        with urllib.request.urlopen(url) as response:
            confirmed = json.loads(response.read().decode())
            return confirmed["meta"]["height"]
    except urllib.error.HTTPError as err:
        if err.status != 400:
            raise
        return None


def is_in_unconfirmed_pool(signature, address):
    """
    Check whether a transaction with the given signature is in the
    address's unconfirmed pool.
    """
    path = f"/account/unconfirmedTransactions?address={address}"
    with urllib.request.urlopen(f"{NODE_URL}{path}") as response:
        pool = json.loads(response.read().decode())["data"]

    target = signature.lower()
    return any(
        entry["transaction"]["signature"].lower() == target
        for entry in pool
    )


def wait_for_confirmation(
    tx_hash, max_attempts=120, wait_seconds=1
):
    """
    Check for confirmation repeatedly until the transaction is confirmed
    or the attempts run out.

    Args:
        tx_hash: hash of the transaction to monitor
        max_attempts: maximum polling attempts
        wait_seconds: seconds to wait between attempts

    Returns:
        True if the transaction was confirmed, False otherwise
    """
    print("\nWaiting for transaction confirmation")
    for attempt in range(1, max_attempts + 1):
        time.sleep(wait_seconds)
        height = get_confirmation_height(tx_hash)
        status = f"confirmed in block {height}" if height else "pending"
        print(f"  Attempt {attempt}: {status}")
        if height:
            return True
    return False


try:
    block_height = get_confirmation_height(transaction_hash)
    if block_height:
        print(f"\nTransaction confirmed in block {block_height}")
    elif not is_in_unconfirmed_pool(transaction_signature,
            signer_address):
        print("\nTransaction not found")
    elif wait_for_confirmation(transaction_hash):
        print("\nTransaction confirmed!")
    else:
        print("\nTransaction not confirmed within the polling window")
except urllib.error.URLError as err:
    print(f"\nCould not reach the node: {err.reason}")

Download source

const NODE_URL = process.env.NODE_URL ||
    'http://libertalia.nemtest.net:7890';
console.log('Using node', NODE_URL);


// Transaction hash to monitor.
const transactionHash = process.env.TRANSACTION_HASH ||
    'AE0B2142DFB75C9C126442EF612944E926BCE63FA34B353CDE409E2E87703C0B';
// Signer's address.
const signerAddress = process.env.SIGNER_ADDRESS ||
    'TBONKWCOWBZYZB2I5JD3LSDBQVBYHB757VN3SKPP';
// Transaction signature.
const transactionSignature = process.env.TRANSACTION_SIGNATURE ||
    '99B1850FADDB964112D030AA0A5C9F8B5B1B6B992B407D9C70F52F089BD651DF' +
    'A7D4991639A48B810EFD98C45060D7AD9AE57FDA37F58561459DCE8D0A747F02';

console.log(`Monitoring transaction: ${transactionHash}`);


/**
 * Query /transaction/get once to check for confirmation.
 * @param {string} txHash - hash of the transaction to check
 * @returns {number|null} height of the block containing the
 *   transaction, or null if it is not confirmed yet
 */
async function getConfirmationHeight(txHash) {
    const url = `${NODE_URL}/transaction/get?hash=${txHash}`;
    const response = await fetch(url);
    if (response.ok) {
        const confirmed = await response.json();
        return confirmed.meta.height;
    }
    if (400 !== response.status)
        throw new Error(`Unexpected status: ${response.status}`);
    return null;
}


/**
 * Check whether a transaction with the given signature is in the
 * address's unconfirmed pool.
 * @param {string} signature - hex signature of the monitored transaction
 * @param {string} address - signer's address
 * @returns {boolean} true if the signature is in the signer's pool
 */
async function isInUnconfirmedPool(signature, address) {
    const path = `/account/unconfirmedTransactions?address=${address}`;
    const response = await fetch(`${NODE_URL}${path}`);
    const pool = (await response.json()).data;

    const target = signature.toLowerCase();
    return pool.some(
        entry => entry.transaction.signature.toLowerCase() === target
    );
}


/**
 * Check for confirmation repeatedly until the transaction is
 * confirmed or the attempts run out.
 * @param {string} txHash - hash of the transaction to monitor
 * @param {number} maxAttempts - maximum polling attempts
 * @param {number} waitSeconds - seconds to wait between attempts
 * @returns {boolean} true if the transaction was confirmed
 */
async function waitForConfirmation(
    txHash,
    maxAttempts = 120,
    waitSeconds = 1
) {
    console.log('\nWaiting for transaction confirmation');
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        await new Promise(resolve => {
            setTimeout(resolve, waitSeconds * 1000);
        });
        const height = await getConfirmationHeight(txHash);
        const status =
            height ? `confirmed in block ${height}` : 'pending';
        console.log(`  Attempt ${attempt}: ${status}`);
        if (height)
            return true;
    }
    return false;
}


try {
    const blockHeight = await getConfirmationHeight(transactionHash);
    if (blockHeight)
        console.log(`\nTransaction confirmed in block ${blockHeight}`);
    else if (!(await isInUnconfirmedPool(transactionSignature,
        signerAddress)))
        console.log('\nTransaction not found');
    else if (await waitForConfirmation(transactionHash))
        console.log('\nTransaction confirmed!');
    else
        console.log('\nConfirmation timed out');
} catch (error) {
    console.log(`\nCould not reach the node: ${error.message}`);
}

Download source

The snippet uses the NODE_URL environment variable to set the NEM API node. If no value is provided, a default one is used.

The tutorial first defines the following reusable functions:

  • : Checks whether the transaction has been confirmed by harvesting and is already part of the blockchain.
  • : Reports whether the transaction is waiting to be confirmed in the unconfirmed pool.
  • : Repeats the confirmation check until the transaction is confirmed or the attempts run out.

The tutorial then calls them together to monitor the transaction, as shown in Putting It All Together.

Code Explanation⚓︎

Finding the Transaction Hash, Address, and Signature⚓︎

# Transaction hash to monitor
transaction_hash = os.getenv(
    "TRANSACTION_HASH",
    "AE0B2142DFB75C9C126442EF612944E926BCE63FA34B353CDE409E2E87703C0B")
# Signer's address
signer_address = os.getenv(
    "SIGNER_ADDRESS",
    "TBONKWCOWBZYZB2I5JD3LSDBQVBYHB757VN3SKPP")
# Transaction signature
transaction_signature = os.getenv(
    "TRANSACTION_SIGNATURE",
    "99B1850FADDB964112D030AA0A5C9F8B5B1B6B992B407D9C70F52F089BD651DF"
    "A7D4991639A48B810EFD98C45060D7AD9AE57FDA37F58561459DCE8D0A747F02")
// Transaction hash to monitor.
const transactionHash = process.env.TRANSACTION_HASH ||
    'AE0B2142DFB75C9C126442EF612944E926BCE63FA34B353CDE409E2E87703C0B';
// Signer's address.
const signerAddress = process.env.SIGNER_ADDRESS ||
    'TBONKWCOWBZYZB2I5JD3LSDBQVBYHB757VN3SKPP';
// Transaction signature.
const transactionSignature = process.env.TRANSACTION_SIGNATURE ||
    '99B1850FADDB964112D030AA0A5C9F8B5B1B6B992B407D9C70F52F089BD651DF' +
    'A7D4991639A48B810EFD98C45060D7AD9AE57FDA37F58561459DCE8D0A747F02';

To monitor a transaction, you need its hash, which is generated after signing. The hash uniquely identifies the transaction on the NEM network.

The snippet also reads a signer's address and the transaction signature. Neither is needed to detect confirmation, but both are used to check whether a transaction is still waiting in the unconfirmed pool.

The snippet uses sample values. Set TRANSACTION_HASH, SIGNER_ADDRESS, and TRANSACTION_SIGNATURE environment variables to override them. All three values are produced when signing a transaction, as shown in the Transfer XEM tutorial.

Checking for Confirmation⚓︎

def get_confirmation_height(tx_hash):
    """
    Query /transaction/get once to check for confirmation.

    Args:
        tx_hash: hash of the transaction to check

    Returns:
        The height of the block containing the transaction, or None
        if the transaction is not confirmed yet
    """
    url = f"{NODE_URL}/transaction/get?hash={tx_hash}"
    try:
        with urllib.request.urlopen(url) as response:
            confirmed = json.loads(response.read().decode())
            return confirmed["meta"]["height"]
    except urllib.error.HTTPError as err:
        if err.status != 400:
            raise
        return None
/**
 * Query /transaction/get once to check for confirmation.
 * @param {string} txHash - hash of the transaction to check
 * @returns {number|null} height of the block containing the
 *   transaction, or null if it is not confirmed yet
 */
async function getConfirmationHeight(txHash) {
    const url = `${NODE_URL}/transaction/get?hash=${txHash}`;
    const response = await fetch(url);
    if (response.ok) {
        const confirmed = await response.json();
        return confirmed.meta.height;
    }
    if (400 !== response.status)
        throw new Error(`Unexpected status: ${response.status}`);
    return null;
}

The function checks whether the transaction is confirmed by querying /transaction/get GET with the transaction hash.

When a transaction has been included in a block, this endpoint returns its contents together with the block height in meta.height, which the function returns.

Otherwise, the endpoint responds with HTTP 400 ("Hash was not found in cache") and the function returns no height, meaning the transaction is not confirmed. A transaction that is not confirmed may still be waiting in the unconfirmed pool, which the next function inspects.

Hash lookup is short-lived

/transaction/get GET reads from a cache with a default retention of 36 hours. The lookup is enabled by default, but node operators can disable it or change the retention.

Querying for a transaction hash older than the retention period returns an HTTP 400 error, even if the transaction is in fact confirmed.

Therefore, when announcing a transaction that might need to be looked up later than this retention period, store its confirmation block height along with its hash. In this way, the transaction can be directly retrieved from the block via the /block/at/public POST endpoint.

Otherwise, the transaction needs to be located by paging through the signer's full history with /account/transfers/all GET, or by searching the blockchain block by block.

Inspecting the Unconfirmed Pool⚓︎

def is_in_unconfirmed_pool(signature, address):
    """
    Check whether a transaction with the given signature is in the
    address's unconfirmed pool.
    """
    path = f"/account/unconfirmedTransactions?address={address}"
    with urllib.request.urlopen(f"{NODE_URL}{path}") as response:
        pool = json.loads(response.read().decode())["data"]

    target = signature.lower()
    return any(
        entry["transaction"]["signature"].lower() == target
        for entry in pool
    )
/**
 * Check whether a transaction with the given signature is in the
 * address's unconfirmed pool.
 * @param {string} signature - hex signature of the monitored transaction
 * @param {string} address - signer's address
 * @returns {boolean} true if the signature is in the signer's pool
 */
async function isInUnconfirmedPool(signature, address) {
    const path = `/account/unconfirmedTransactions?address=${address}`;
    const response = await fetch(`${NODE_URL}${path}`);
    const pool = (await response.json()).data;

    const target = signature.toLowerCase();
    return pool.some(
        entry => entry.transaction.signature.toLowerCase() === target
    );
}

queries /account/unconfirmedTransactions GET for the signer's address and reports whether the monitored transaction is among the pending list.

Invalid transactions never enter the pool

A transaction that fails validation does not reach the unconfirmed pool: the receiving node rejects it immediately when announced, as shown in the Transfer XEM tutorial.

The above endpoint response omits each entry's hash, so the function matches by signature instead. A transaction's signature is unique and appears under transaction.signature in every pool entry. For multisig transactions, this is the signature of the announced wrapper, not of the inner transaction.

The function returns:

  • : the transaction is still in the unconfirmed pool, waiting to be included in a block.
  • : the transaction is not in the response. Possible causes include: it has not arrived at this node yet, it has already been confirmed, it was dropped from the pool, or it was left out of the response.

    The response is limited to 25 transactions

    The endpoint returns at most the 25 most recent transactions involving the address. Incoming transactions count toward this limit too, so on a busy account the monitored transaction can be missing from the response while it is still in the unconfirmed pool.

Waiting for Confirmation⚓︎

def wait_for_confirmation(
    tx_hash, max_attempts=120, wait_seconds=1
):
    """
    Check for confirmation repeatedly until the transaction is confirmed
    or the attempts run out.

    Args:
        tx_hash: hash of the transaction to monitor
        max_attempts: maximum polling attempts
        wait_seconds: seconds to wait between attempts

    Returns:
        True if the transaction was confirmed, False otherwise
    """
    print("\nWaiting for transaction confirmation")
    for attempt in range(1, max_attempts + 1):
        time.sleep(wait_seconds)
        height = get_confirmation_height(tx_hash)
        status = f"confirmed in block {height}" if height else "pending"
        print(f"  Attempt {attempt}: {status}")
        if height:
            return True
    return False
/**
 * Check for confirmation repeatedly until the transaction is
 * confirmed or the attempts run out.
 * @param {string} txHash - hash of the transaction to monitor
 * @param {number} maxAttempts - maximum polling attempts
 * @param {number} waitSeconds - seconds to wait between attempts
 * @returns {boolean} true if the transaction was confirmed
 */
async function waitForConfirmation(
    txHash,
    maxAttempts = 120,
    waitSeconds = 1
) {
    console.log('\nWaiting for transaction confirmation');
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        await new Promise(resolve => {
            setTimeout(resolve, waitSeconds * 1000);
        });
        const height = await getConfirmationHeight(txHash);
        const status =
            height ? `confirmed in block ${height}` : 'pending';
        console.log(`  Attempt ${attempt}: ${status}`);
        if (height)
            return true;
    }
    return false;
}

The function calls every second until the transaction is confirmed, or two minutes ellapse (configurable timeout).

The function returns as soon as a check reports a confirmation. When the attempts run out, it returns instead. This means the transaction was not confirmed within the polling window, not that it failed, but this is a rare case.

Putting It All Together⚓︎

try:
    block_height = get_confirmation_height(transaction_hash)
    if block_height:
        print(f"\nTransaction confirmed in block {block_height}")
    elif not is_in_unconfirmed_pool(transaction_signature,
            signer_address):
        print("\nTransaction not found")
    elif wait_for_confirmation(transaction_hash):
        print("\nTransaction confirmed!")
    else:
        print("\nTransaction not confirmed within the polling window")
except urllib.error.URLError as err:
    print(f"\nCould not reach the node: {err.reason}")
try {
    const blockHeight = await getConfirmationHeight(transactionHash);
    if (blockHeight)
        console.log(`\nTransaction confirmed in block ${blockHeight}`);
    else if (!(await isInUnconfirmedPool(transactionSignature,
        signerAddress)))
        console.log('\nTransaction not found');
    else if (await waitForConfirmation(transactionHash))
        console.log('\nTransaction confirmed!');
    else
        console.log('\nConfirmation timed out');
} catch (error) {
    console.log(`\nCould not reach the node: ${error.message}`);
}

The snippet starts with a call to to check if the transaction is already confirmed.

Confirmed transactions can still be reversed

A confirmed transaction has been included in a block but is not yet irreversible. Until enough subsequent blocks are added to surpass the rewrite limit, rollbacks are still possible.

If the transaction is not already part of a block, looks for it in the unconfirmed pool. A transaction that is neither confirmed nor in this pool is reported as not found.

Only when the transaction is waiting in the unconfirmed pool does the snippet call to poll until the transaction is confirmed or the polling window ends.

Only rejection or a passed deadline means failure

There are only two ways to know a transaction will never confirm: it was rejected when announced, or its deadline has passed.

A transaction disappearing from one node's unconfirmed pool does not mean it has failed. Each node maintains its own pool, and another peer may still hold and eventually confirm the transaction. For example, a node may restart with an empty pool or remove older transactions while managing pool capacity.

Because announcement rejections are returned immediately, the deadline provides the final verdict. Once network time passes the deadline, the transaction can no longer be included in a block and it is safe to announce a replacement transaction.

Compare the deadline chosen when building the transaction against the network time returned by /time-sync/network-time GET.

Output⚓︎

The following output shows a typical run monitoring a freshly-announced transaction:

Using node http://libertalia.nemtest.net:7890
Monitoring transaction: AE0B2142DFB75C9C126442EF612944E926BCE63FA34B353CDE409E2E87703C0B

Waiting for transaction confirmation
  Attempt 1: pending
  Attempt 2: pending
  Attempt 3: pending
  Attempt 4: pending
  Attempt 5: pending
  Attempt 6: confirmed in block 652601

Transaction confirmed!

Some highlights from the output:

  • Transaction hash (line 2): The hash of the transaction to monitor, which uniquely identifies it on the network.

  • Polling start (line 4): Polling starts because the transaction was not already present in the blockchain and was found waiting in the unconfirmed pool.

  • Polling attempts (lines 5-9): Each attempt reports pending while the transaction waits to be included in a block.

  • Confirmation (line 10): A polling attempt finally reports the inclusion in block 652601.

  • Final outcome (line 12): The transaction is confirmed and monitoring ends.

The number of attempts and timing vary depending on network conditions and block production rate.

To see the transaction from the network's perspective, visit the NEM Testnet Explorer and search for the transaction hash.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Check for confirmation /transaction/get GET
Inspect the unconfirmed pool /account/unconfirmedTransactions GET
Wait for confirmation /transaction/get GET
Detect when a transaction will never confirm /time-sync/network-time GET

Next Steps⚓︎

For production applications, consider these improvements:

  • Wait past the rewrite limit. A confirmed transaction can still be rolled back until enough subsequent blocks have been added. See the rewrite limit for the practical threshold.
  • Query multiple nodes. Check status across several nodes for greater reliability and protection against single-node issues.
  • Use WebSockets: Replace polling with WebSocket subscriptions for real-time updates without repeated API calls. See the Listening to Transaction Flow WebSocket tutorial.