Skip to content

Listening to Transaction Flow⚓︎

Symbol provides WebSocket channels that send real-time notifications as a transaction moves through the confirmation process for a specific account. Compared to polling the /transactionStatus/{hash} GET endpoint, WebSockets push updates as they happen without the overhead of repeated API calls.

This tutorial shows how to subscribe to transaction channels, announce a minimal Transfer Transaction, and wait for its confirmation using WebSockets.

Note

For a polling-based approach, see the Monitoring Transaction Status tutorial.

Prerequisites⚓︎

Before you start, make sure to:

Additionally, install the language-specific WebSocket library:

Install the websockets library:

pip install websockets

This tutorial uses the native WebSocket API available in Node.js 22 or later. No additional packages are required.

Full Code⚓︎

import asyncio
import json
import os
import urllib.request

from symbolchain.CryptoTypes import PrivateKey
from symbolchain.facade.SymbolFacade import SymbolFacade
from symbolchain.symbol.Network import NetworkTimestamp
from symbolchain.sc import Amount
from websockets import connect

NODE_URL = os.getenv('NODE_URL', 'https://reference.symboltest.net:3001')
WS_URL = NODE_URL.replace('http', 'ws', 1) + '/ws'
print(f'Using node {NODE_URL}')

MONITOR_ADDRESS = os.getenv(
    'MONITOR_ADDRESS',
    'TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I'
)
print(f'Monitoring address: {MONITOR_ADDRESS}')

SIGNER_PRIVATE_KEY = os.getenv(
    'SIGNER_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000'
)
facade = SymbolFacade('testnet')
signer_key_pair = SymbolFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY))


async def main():
    async with connect(WS_URL) as websocket:
        # Connect to WebSocket
        response = json.loads(await websocket.recv())
        uid = response['uid']
        print(f'Connected to {WS_URL} with uid {uid}')

        # Subscribe to transaction channels
        channels = [
            f'unconfirmedAdded/{MONITOR_ADDRESS}',
            f'unconfirmedRemoved/{MONITOR_ADDRESS}',
            f'confirmedAdded/{MONITOR_ADDRESS}',
        ]
        for channel in channels:
            await websocket.send(json.dumps(
                {'uid': uid, 'subscribe': channel}
            ))
            name = channel.split('/')[0]
            print(f'Subscribed to {name} channel')

        # Build and announce a transfer transaction
        with urllib.request.urlopen(f'{NODE_URL}/node/time') as resp:
            time_json = json.loads(resp.read().decode())
            timestamp = NetworkTimestamp(int(
                time_json['communicationTimestamps']['receiveTimestamp']))

        with urllib.request.urlopen(
            f'{NODE_URL}/network/fees/transaction'
        ) as resp:
            fee_json = json.loads(resp.read().decode())
            fee_mult = max(
                fee_json['medianFeeMultiplier'],
                fee_json['minFeeMultiplier'])

        transaction = facade.transaction_factory.create({
            'type': 'transfer_transaction_v1',
            'signer_public_key': signer_key_pair.public_key,
            'deadline': timestamp.add_hours(2).timestamp,
            'recipient_address': MONITOR_ADDRESS,
        })
        transaction.fee = Amount(fee_mult * transaction.size)

        signature = facade.sign_transaction(signer_key_pair, transaction)
        json_payload = facade.transaction_factory.attach_signature(
            transaction, signature)
        transaction_hash = str(facade.hash_transaction(transaction))

        announce_request = urllib.request.Request(
            f'{NODE_URL}/transactions',
            data=json_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='PUT'
        )
        with urllib.request.urlopen(announce_request) as resp:
            resp.read()
        print(f'Announced transaction {transaction_hash[:16]}...')

        # Wait for confirmation via WebSocket
        async for raw_message in websocket:
            message = json.loads(raw_message)
            topic = message['topic']
            message_hash = message['data']['meta']['hash']
            name = topic.split('/')[0]
            print(f'{name}: hash={message_hash[:16]}...')

            if (name == 'confirmedAdded'
                    and message_hash == transaction_hash):
                print(f'Transaction {transaction_hash[:16]}... confirmed')
                break

        # Unsubscribe before closing
        for channel in channels:
            await websocket.send(json.dumps({
                'uid': uid,
                'unsubscribe': channel
            }))
        print('Unsubscribed from all channels')


try:
    asyncio.run(main())
except Exception as error:
    print(error)

Download source

import { PrivateKey } from 'symbol-sdk';
import { SymbolFacade, NetworkTimestamp, models } from 'symbol-sdk/symbol';

const NODE_URL = process.env.NODE_URL
    || 'https://reference.symboltest.net:3001';
const WS_URL = NODE_URL.replace('http', 'ws') + '/ws';
console.log(`Using node ${NODE_URL}`);

const MONITOR_ADDRESS = process.env.MONITOR_ADDRESS
    || 'TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I';
console.log(`Monitoring address: ${MONITOR_ADDRESS}`);

const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY
    || '0000000000000000000000000000000000000000000000000000000000000000';
const facade = new SymbolFacade('testnet');
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));

try {
    // Connect to WebSocket
    const websocket = new WebSocket(WS_URL);
    const uid = await new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            resolve(message.uid);
        }, { once: true });
    });
    console.log(`Connected to ${WS_URL} with uid ${uid}`);

    // Subscribe to transaction channels
    const channels = [
        `unconfirmedAdded/${MONITOR_ADDRESS}`,
        `unconfirmedRemoved/${MONITOR_ADDRESS}`,
        `confirmedAdded/${MONITOR_ADDRESS}`,
    ];
    for (const channel of channels) {
        websocket.send(JSON.stringify({ uid, subscribe: channel }));
        const name = channel.split('/')[0];
        console.log(`Subscribed to ${name} channel`);
    }

    // Build and announce a transfer transaction
    const timeResponse = await fetch(`${NODE_URL}/node/time`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);

    const feeResponse = await fetch(
        `${NODE_URL}/network/fees/transaction`);
    const feeJSON = await feeResponse.json();
    const feeMult = Math.max(
        feeJSON.medianFeeMultiplier, feeJSON.minFeeMultiplier);

    const transaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v1',
        signerPublicKey: signerKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        recipientAddress: MONITOR_ADDRESS,
    });
    transaction.fee = new models.Amount(feeMult * transaction.size);

    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    const transactionHash = facade.hashTransaction(transaction).toString();

    const confirmed = new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            const topic = message.topic;
            const messageHash = message.data.meta.hash;
            const name = topic.split('/')[0];
            console.log(
                `${name}: hash=${messageHash.substring(0, 16)}...`);

            if (name === 'confirmedAdded'
                && messageHash === transactionHash) {
                    console.log(
                        `Transaction ${transactionHash.substring(0, 16)}`
                        + '... confirmed');
                    resolve();
            }
        });
    });
    await fetch(`${NODE_URL}/transactions`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    console.log(
        'Announced transaction '
        + `${transactionHash.substring(0, 16)}...`);

    // Wait for confirmation via WebSocket
    await confirmed;

    // Unsubscribe before closing
    for (const channel of channels) {
        websocket.send(JSON.stringify({ uid, unsubscribe: channel }));
    }
    console.log('Unsubscribed from all channels');
    websocket.close();
} catch (error) {
    console.error(error);
}

Download source

The snippet uses the NODE_URL environment variable to set the Symbol API node. If no value is provided, a default one is used. The WebSocket URL is derived from NODE_URL by replacing the HTTP protocol with the WebSocket protocol and appending /ws.

Code Explanation⚓︎

Setting Up the Monitored Address and Signer⚓︎

MONITOR_ADDRESS = os.getenv(
    'MONITOR_ADDRESS',
    'TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I'
)
print(f'Monitoring address: {MONITOR_ADDRESS}')

SIGNER_PRIVATE_KEY = os.getenv(
    'SIGNER_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000'
)
facade = SymbolFacade('testnet')
signer_key_pair = SymbolFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY))
const MONITOR_ADDRESS = process.env.MONITOR_ADDRESS
    || 'TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I';
console.log(`Monitoring address: ${MONITOR_ADDRESS}`);

const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY
    || '0000000000000000000000000000000000000000000000000000000000000000';
const facade = new SymbolFacade('testnet');
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));

Each transaction WebSocket channel is scoped to a specific address. The MONITOR_ADDRESS environment variable sets the address to watch. The channel sends a notification whenever this address is involved in a transaction, whether as sender, recipient, or any other role derived from the transaction's content (for example, signer of an embedded transaction in an aggregate transaction).

To trigger notifications, this tutorial sends a transfer transaction to the monitored address. The sender's private key is read from SIGNER_PRIVATE_KEY.

If any of these environment variables is not provided, the tutorial provides default values that correspond to the same account.

Connecting to the WebSocket⚓︎

    async with connect(WS_URL) as websocket:
        # Connect to WebSocket
        response = json.loads(await websocket.recv())
        uid = response['uid']
        print(f'Connected to {WS_URL} with uid {uid}')
    // Connect to WebSocket
    const websocket = new WebSocket(WS_URL);
    const uid = await new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            resolve(message.uid);
        }, { once: true });
    });
    console.log(`Connected to ${WS_URL} with uid ${uid}`);

The code opens a WebSocket connection to the node's /ws endpoint. Upon connecting, the server sends a message containing a unique identifier (uid) that must be included in all subsequent subscription requests.

See the WebSocket reference for details on the connection protocol.

Subscribing to Channels⚓︎

        # Subscribe to transaction channels
        channels = [
            f'unconfirmedAdded/{MONITOR_ADDRESS}',
            f'unconfirmedRemoved/{MONITOR_ADDRESS}',
            f'confirmedAdded/{MONITOR_ADDRESS}',
        ]
        for channel in channels:
            await websocket.send(json.dumps(
                {'uid': uid, 'subscribe': channel}
            ))
            name = channel.split('/')[0]
            print(f'Subscribed to {name} channel')
    // Subscribe to transaction channels
    const channels = [
        `unconfirmedAdded/${MONITOR_ADDRESS}`,
        `unconfirmedRemoved/${MONITOR_ADDRESS}`,
        `confirmedAdded/${MONITOR_ADDRESS}`,
    ];
    for (const channel of channels) {
        websocket.send(JSON.stringify({ uid, subscribe: channel }));
        const name = channel.split('/')[0];
        console.log(`Subscribed to ${name} channel`);
    }

The code subscribes to three address-scoped channels, appending the monitored address to each channel name:

Each subscription message includes the uid received during the connection step and the full channel name with the monitored address.

Building and Signing a Transfer Transaction⚓︎

        # Build and announce a transfer transaction
        with urllib.request.urlopen(f'{NODE_URL}/node/time') as resp:
            time_json = json.loads(resp.read().decode())
            timestamp = NetworkTimestamp(int(
                time_json['communicationTimestamps']['receiveTimestamp']))

        with urllib.request.urlopen(
            f'{NODE_URL}/network/fees/transaction'
        ) as resp:
            fee_json = json.loads(resp.read().decode())
            fee_mult = max(
                fee_json['medianFeeMultiplier'],
                fee_json['minFeeMultiplier'])

        transaction = facade.transaction_factory.create({
            'type': 'transfer_transaction_v1',
            'signer_public_key': signer_key_pair.public_key,
            'deadline': timestamp.add_hours(2).timestamp,
            'recipient_address': MONITOR_ADDRESS,
        })
        transaction.fee = Amount(fee_mult * transaction.size)

        signature = facade.sign_transaction(signer_key_pair, transaction)
        json_payload = facade.transaction_factory.attach_signature(
            transaction, signature)
        transaction_hash = str(facade.hash_transaction(transaction))
    // Build and announce a transfer transaction
    const timeResponse = await fetch(`${NODE_URL}/node/time`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);

    const feeResponse = await fetch(
        `${NODE_URL}/network/fees/transaction`);
    const feeJSON = await feeResponse.json();
    const feeMult = Math.max(
        feeJSON.medianFeeMultiplier, feeJSON.minFeeMultiplier);

    const transaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v1',
        signerPublicKey: signerKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        recipientAddress: MONITOR_ADDRESS,
    });
    transaction.fee = new models.Amount(feeMult * transaction.size);

    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    const transactionHash = facade.hashTransaction(transaction).toString();

This tutorial builds a minimal Transfer Transaction to the monitored address, with no mosaics and no message. A transfer is used for simplicity, but any transaction type triggers the same WebSocket notifications.

The transaction is built as usual: fetching the network time and fee multiplier, creating the transaction descriptor, and signing it. The hash is computed locally so it can be matched against incoming WebSocket messages later.

Announcing and Waiting for Confirmation⚓︎

        announce_request = urllib.request.Request(
            f'{NODE_URL}/transactions',
            data=json_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='PUT'
        )
        with urllib.request.urlopen(announce_request) as resp:
            resp.read()
        print(f'Announced transaction {transaction_hash[:16]}...')

        # Wait for confirmation via WebSocket
        async for raw_message in websocket:
            message = json.loads(raw_message)
            topic = message['topic']
            message_hash = message['data']['meta']['hash']
            name = topic.split('/')[0]
            print(f'{name}: hash={message_hash[:16]}...')

            if (name == 'confirmedAdded'
                    and message_hash == transaction_hash):
                print(f'Transaction {transaction_hash[:16]}... confirmed')
                break
    const confirmed = new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            const topic = message.topic;
            const messageHash = message.data.meta.hash;
            const name = topic.split('/')[0];
            console.log(
                `${name}: hash=${messageHash.substring(0, 16)}...`);

            if (name === 'confirmedAdded'
                && messageHash === transactionHash) {
                    console.log(
                        `Transaction ${transactionHash.substring(0, 16)}`
                        + '... confirmed');
                    resolve();
            }
        });
    });
    await fetch(`${NODE_URL}/transactions`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    console.log(
        'Announced transaction '
        + `${transactionHash.substring(0, 16)}...`);

    // Wait for confirmation via WebSocket
    await confirmed;

The code announces the transaction and then listens for incoming messages, printing each one.

Announce after subscribing to channels

Always announce the transaction after subscribing to the WebSocket channels to ensure the listener is ready. Otherwise, notifications could arrive before the WebSocket is listening.

Each message includes a topic field identifying the channel and a data object with the event payload.

For confirmedAdded and unconfirmedAdded messages, the payload follows the TransactionInfoDTO schema. For unconfirmedRemoved messages, the payload contains only the transaction hash (meta.hash).

When a confirmedAdded message arrives whose hash matches the announced transaction, the program prints a confirmation message and exits.

The expected sequence for a successful transaction is described in the Transaction Lifecycle section:

  1. unconfirmedAdded: The transaction enters the unconfirmed pool.
  2. unconfirmedRemoved: The transaction leaves the unconfirmed pool.
  3. confirmedAdded: The transaction is confirmed in a block.

Unsubscribing from Channels⚓︎

        # Unsubscribe before closing
        for channel in channels:
            await websocket.send(json.dumps({
                'uid': uid,
                'unsubscribe': channel
            }))
        print('Unsubscribed from all channels')
    // Unsubscribe before closing
    for (const channel of channels) {
        websocket.send(JSON.stringify({ uid, unsubscribe: channel }));
    }
    console.log('Unsubscribed from all channels');
    websocket.close();

After confirmation, the code sends unsubscribe messages for all three channels before closing the connection.

Output⚓︎

Using node https://reference.symboltest.net:3001
Monitoring address: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Connected to wss://reference.symboltest.net:3001/ws with uid Hj3kL9mN2pQr5tVw=
Subscribed to unconfirmedAdded channel
Subscribed to unconfirmedRemoved channel
Subscribed to confirmedAdded channel
Announced transaction 7A3F1B9E4C2D8A65...
unconfirmedAdded: hash=7A3F1B9E4C2D8A65...
unconfirmedRemoved: hash=7A3F1B9E4C2D8A65...
confirmedAdded: hash=7A3F1B9E4C2D8A65...
Transaction 7A3F1B9E4C2D8A65... confirmed
Unsubscribed from all channels

The output shows:

  • Address (line 2): The monitored address.
  • Connection (line 3): The WebSocket connection is established and the server returns a unique uid.
  • Subscriptions (lines 4-6): All three transaction channels are subscribed.
  • Announcement (line 7): The transaction is announced and its hash is printed.
  • Transaction flow (lines 8-10): The transaction moves from unconfirmedAdded to unconfirmedRemoved to confirmedAdded, showing the full confirmation lifecycle.
  • Confirmation (line 11): The hash from confirmedAdded matches the announced transaction, confirming success.
  • Unsubscribe (line 12): The code unsubscribes from all channels.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Subscribe to unconfirmedAdded unconfirmedAdded/{address} WS
Subscribe to unconfirmedRemoved unconfirmedRemoved/{address} WS
Subscribe to confirmedAdded confirmedAdded/{address} WS
Handle transaction messages TransactionInfoDTO

Next Steps⚓︎

To detect rejected transactions and their error codes, see the Listening to Transaction Errors tutorial.