NEM provides WebSocket channels that send real-time notifications as a transaction moves
through the confirmation process for a specific account.
Compared to polling the /transaction/getGET 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.
importasyncioimportjsonimportosimportrandomimporturllib.requestimportuuidimportstomperfromsymbolchain.CryptoTypesimportPrivateKeyfromsymbolchain.facade.NemFacadeimportNemFacadefromsymbolchain.ncimportAmountfromsymbolchain.nem.FeeCalculatorimportcalculate_transaction_feefromsymbolchain.nem.NetworkimportNetworkTimestampfromwebsocketsimportconnectNODE_URL=os.getenv('NODE_URL','http://libertalia.nemtest.net:7890')WS_URL=NODE_URL.replace(':7890',':7778')print(f'Using node {NODE_URL}')# SockJS has no Python client library.# These helpers wrap the raw WebSocket transport to mirror a STOMP client.defsockjs_url(endpoint_url):# SockJS raw WebSocket transport adds a random server and session idserver=random.randint(100,999)session=uuid.uuid4().hexws_base=endpoint_url.replace('http','ws',1)returnf'{ws_base}/{server}/{session}/websocket'asyncdefsend_frame(websocket,frame):# SockJS wraps each client payload as a JSON array of frame stringsawaitwebsocket.send(json.dumps([frame]))asyncdefstomp_connect(websocket):awaitwebsocket.recv()# consume the SockJS open frameawaitsend_frame(websocket,stomper.connect('','',NODE_URL,heartbeats=(0,0)))asyncdefstomp_subscribe(websocket,destination,sub_id):awaitsend_frame(websocket,stomper.subscribe(destination,sub_id))asyncdefstomp_send(websocket,destination,body):awaitsend_frame(websocket,stomper.send(destination,body))asyncdefstomp_unsubscribe(websocket,sub_id):awaitsend_frame(websocket,stomper.unsubscribe(sub_id))asyncdefstomp_disconnect(websocket):awaitsend_frame(websocket,stomper.disconnect())defstomp_messages(raw_frame):# Yield each STOMP MESSAGE frame in a SockJS data frameif'a'!=raw_frame[0]:# skip 'o' open, 'h' heartbeat, 'c' closereturnforpayloadinjson.loads(raw_frame[1:]):frame=stomper.unpack_frame(payload)if'MESSAGE'==frame['cmd']:yieldframeasyncdefstomp_frames(websocket):# Yield each STOMP MESSAGE frame as it arrivesasyncforraw_frameinwebsocket:forframeinstomp_messages(raw_frame):yieldframe# Set up the monitored address and signerMONITOR_ADDRESS=os.getenv('MONITOR_ADDRESS','TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4')print(f'Monitoring address: {MONITOR_ADDRESS}')SIGNER_PRIVATE_KEY=os.getenv('SIGNER_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000000')facade=NemFacade('testnet')signer_key_pair=NemFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY))asyncdefmain():# Build and sign a transfer to the monitored addresswithurllib.request.urlopen(f'{NODE_URL}/time-sync/network-time')asresp:network_time=json.loads(resp.read().decode())['receiveTimeStamp']//1000timestamp=NetworkTimestamp(network_time)deadline=timestamp.add_hours(2)transaction=facade.transaction_factory.create({'type':'transfer_transaction_v2','signer_public_key':signer_key_pair.public_key,'timestamp':timestamp.timestamp,'deadline':deadline.timestamp,'recipient_address':MONITOR_ADDRESS,'amount':0,})transaction.fee=Amount(calculate_transaction_fee(transaction))signature=facade.sign_transaction(signer_key_pair,transaction)json_payload=facade.transaction_factory.attach_signature(transaction,signature)transaction_hash=str(facade.hash_transaction(transaction)).upper()# Connect to the WebSocketendpoint=f'{WS_URL}/w/messages'asyncwithconnect(sockjs_url(endpoint))aswebsocket:awaitstomp_connect(websocket)print(f'Connected to {WS_URL}')# Subscribe to the account and transaction channelsaccount_channel=f'/account/{MONITOR_ADDRESS}'channels={account_channel:'id-0',f'/unconfirmed/{MONITOR_ADDRESS}':'id-1',f'/transactions/{MONITOR_ADDRESS}':'id-2',}forchannel,sub_idinchannels.items():awaitstomp_subscribe(websocket,channel,sub_id)print(f'Subscribed to {channel} channel')# Register the account and confirm it is activeawaitstomp_send(websocket,'/w/api/account/get',json.dumps({'account':MONITOR_ADDRESS}))asyncforframeinstomp_frames(websocket):ifaccount_channel==frame['headers']['destination']:balance=json.loads(frame['body'])['account']['balance']print(f'Account update: balance={balance}')breakprint('Account registered')# Announce the transaction and wait for it to confirmprint(f'Announcing transaction {transaction_hash[:16]}...')announce_request=urllib.request.Request(f'{NODE_URL}/transaction/announce',data=json_payload.encode(),headers={'Content-Type':'application/json'},method='POST')withurllib.request.urlopen(announce_request)asresp:result=json.loads(resp.read().decode())if'SUCCESS'==result['message']:confirmed=Falseasyncforframeinstomp_frames(websocket):destination=frame['headers']['destination']body=json.loads(frame['body'])ifaccount_channel==destination:balance=body['account']['balance']print(f'Account update: balance={balance}')ifconfirmed:breakelif'/transactions/'indestination:message_hash=body['meta']['hash']['data']print(f'confirmed: hash={message_hash[:16]}...')ifmessage_hash.upper()==transaction_hash:short_hash=transaction_hash[:16]print(f'Transaction {short_hash}... confirmed')confirmed=Trueelse:message_hash=body['meta']['hash']['data']ifmessage_hash.upper()==transaction_hash:print(f'unconfirmed: hash={message_hash[:16]}...')else:print(f'Transaction rejected: {result["message"]}')# Unsubscribe before closingforsub_idinchannels.values():awaitstomp_unsubscribe(websocket,sub_id)print('Unsubscribed from all channels')awaitstomp_disconnect(websocket)try:asyncio.run(main())exceptExceptionaserror:print(error)
import{Client}from'@stomp/stompjs';importSockJSfrom'sockjs-client';import{PrivateKey}from'symbol-sdk';import{NemFacade,NetworkTimestamp,calculateTransactionFee,models}from'symbol-sdk/nem';constNODE_URL=process.env.NODE_URL||'http://libertalia.nemtest.net:7890';constWS_URL=NODE_URL.replace(':7890',':7778');console.log(`Using node ${NODE_URL}`);// Set up the monitored address and signerconstMONITOR_ADDRESS=process.env.MONITOR_ADDRESS||'TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4';console.log(`Monitoring address: ${MONITOR_ADDRESS}`);constSIGNER_PRIVATE_KEY=process.env.SIGNER_PRIVATE_KEY||'0000000000000000000000000000000000000000000000000000000000000000';constfacade=newNemFacade('testnet');constsignerKeyPair=newNemFacade.KeyPair(newPrivateKey(SIGNER_PRIVATE_KEY));try{// Build and sign a transfer to the monitored addressconsttimeResponse=awaitfetch(`${NODE_URL}/time-sync/network-time`);constnetworkTime=Math.floor((awaittimeResponse.json()).receiveTimeStamp/1000);consttimestamp=newNetworkTimestamp(networkTime);constdeadline=timestamp.addHours(2);consttransaction=facade.transactionFactory.create({type:'transfer_transaction_v2',signerPublicKey:signerKeyPair.publicKey.toString(),timestamp:timestamp.timestamp,deadline:deadline.timestamp,recipientAddress:MONITOR_ADDRESS,amount:0n});transaction.fee=newmodels.Amount(calculateTransactionFee(transaction));constsignature=facade.signTransaction(signerKeyPair,transaction);constjsonPayload=facade.transactionFactory.static.attachSignature(transaction,signature);consttransactionHash=facade.hashTransaction(transaction).toString().toUpperCase();constshortHash=transactionHash.substring(0,16);// Connect to the WebSocketconstclient=newClient({webSocketFactory:()=>newSockJS(`${WS_URL}/w/messages`)});awaitnewPromise(resolve=>{client.onConnect=resolve;client.activate();});console.log(`Connected to ${WS_URL}`);// Subscribe to the account and transaction channelsconstaccountChannel=`/account/${MONITOR_ADDRESS}`;constchannels={[accountChannel]:'id-0',[`/unconfirmed/${MONITOR_ADDRESS}`]:'id-1',[`/transactions/${MONITOR_ADDRESS}`]:'id-2'};letconfirmed=false;letresolveRegistered;letresolveDone;constregistered=newPromise(resolve=>{resolveRegistered=resolve;});constdone=newPromise(resolve=>{resolveDone=resolve;});constonMessage=message=>{constbody=JSON.parse(message.body);const{destination}=message.headers;if(accountChannel===destination){const{balance}=body.account;console.log(`Account update: balance=${balance}`);resolveRegistered();if(confirmed)resolveDone();}elseif(destination.includes('/transactions/')){constmessageHash=body.meta.hash.data;console.log(`confirmed: hash=${messageHash.substring(0,16)}...`);if(messageHash.toUpperCase()===transactionHash){console.log(`Transaction ${shortHash}... confirmed`);confirmed=true;}}else{constmessageHash=body.meta.hash.data;if(messageHash.toUpperCase()===transactionHash){console.log('unconfirmed: hash='+`${messageHash.substring(0,16)}...`);}}};for(const[channel,id]ofObject.entries(channels)){client.subscribe(channel,onMessage,{id});console.log(`Subscribed to ${channel} channel`);}// Register the account and confirm it is activeclient.publish({destination:'/w/api/account/get',body:JSON.stringify({account:MONITOR_ADDRESS})});awaitregistered;console.log('Account registered');// Announce the transaction and wait for it to confirmconsole.log(`Announcing transaction ${shortHash}...`);constresponse=awaitfetch(`${NODE_URL}/transaction/announce`,{method:'POST',headers:{'Content-Type':'application/json'},body:jsonPayload});constannounceResult=awaitresponse.json();if('SUCCESS'===announceResult.message)awaitdone;elseconsole.log(`Transaction rejected: ${announceResult.message}`);// Unsubscribe before closingfor(constidofObject.values(channels))client.unsubscribe(id);console.log('Unsubscribed from all channels');client.deactivate();}catch(error){console.error(error);}
There is no SockJS client library for Python, so a few small helper methods are defined at the top of the file
for convenience.
The snippet uses the NODE_URL environment variable to set the NEM node.
If no value is provided, a default one is used.
WS_URL defines the WebSocket endpoint for the same node.
It is derived from NODE_URL by replacing port 7890, the default HTTP API port, with 7778, the default NIS
WebSocket port.
# Set up the monitored address and signerMONITOR_ADDRESS=os.getenv('MONITOR_ADDRESS','TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4')print(f'Monitoring address: {MONITOR_ADDRESS}')SIGNER_PRIVATE_KEY=os.getenv('SIGNER_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000000')facade=NemFacade('testnet')signer_key_pair=NemFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY))
// Set up the monitored address and signerconstMONITOR_ADDRESS=process.env.MONITOR_ADDRESS||'TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4';console.log(`Monitoring address: ${MONITOR_ADDRESS}`);constSIGNER_PRIVATE_KEY=process.env.SIGNER_PRIVATE_KEY||'0000000000000000000000000000000000000000000000000000000000000000';constfacade=newNemFacade('testnet');constsignerKeyPair=newNemFacade.KeyPair(newPrivateKey(SIGNER_PRIVATE_KEY));
This step sets up the address to monitor and the account that sends a transfer to it.
MONITOR_ADDRESS is the address to watch.
The channels this tutorial subscribes to are scoped to this address and notify whenever it is involved in a transaction,
for example as the sender or recipient of a transfer.
The WebSocket API expects the address uppercase and without hyphens.
SIGNER_PRIVATE_KEY is the private key of the account that sends the transfer, which triggers the notifications.
If any of these environment variables is not provided, the tutorial provides default values.
# Build and sign a transfer to the monitored addresswithurllib.request.urlopen(f'{NODE_URL}/time-sync/network-time')asresp:network_time=json.loads(resp.read().decode())['receiveTimeStamp']//1000timestamp=NetworkTimestamp(network_time)deadline=timestamp.add_hours(2)transaction=facade.transaction_factory.create({'type':'transfer_transaction_v2','signer_public_key':signer_key_pair.public_key,'timestamp':timestamp.timestamp,'deadline':deadline.timestamp,'recipient_address':MONITOR_ADDRESS,'amount':0,})transaction.fee=Amount(calculate_transaction_fee(transaction))signature=facade.sign_transaction(signer_key_pair,transaction)json_payload=facade.transaction_factory.attach_signature(transaction,signature)transaction_hash=str(facade.hash_transaction(transaction)).upper()
// Build and sign a transfer to the monitored addressconsttimeResponse=awaitfetch(`${NODE_URL}/time-sync/network-time`);constnetworkTime=Math.floor((awaittimeResponse.json()).receiveTimeStamp/1000);consttimestamp=newNetworkTimestamp(networkTime);constdeadline=timestamp.addHours(2);consttransaction=facade.transactionFactory.create({type:'transfer_transaction_v2',signerPublicKey:signerKeyPair.publicKey.toString(),timestamp:timestamp.timestamp,deadline:deadline.timestamp,recipientAddress:MONITOR_ADDRESS,amount:0n});transaction.fee=newmodels.Amount(calculateTransactionFee(transaction));constsignature=facade.signTransaction(signerKeyPair,transaction);constjsonPayload=facade.transactionFactory.static.attachSignature(transaction,signature);consttransactionHash=facade.hashTransaction(transaction).toString().toUpperCase();constshortHash=transactionHash.substring(0,16);
This tutorial builds a minimal Transfer transaction to the monitored address, with a zero amount, no mosaics, and
no message.
A transfer is used for simplicity, but any transaction type triggers the same WebSocket notifications.
The transaction is built the same way as in the
Transfer XEM tutorial: fetching the network time, creating the transaction, and
signing it.
Signing the transaction produces its hash, which uniquely identifies it.
The code stores this hash because transaction channel notifications include the transaction hash.
The message handler, defined later, compares each received hash with the stored value to identify notifications for this
transaction.
The transaction is prepared, but it is not announced yet.
The announcement happens after the channel subscriptions are established, so the notifications it triggers are not
missed.
# Connect to the WebSocketendpoint=f'{WS_URL}/w/messages'asyncwithconnect(sockjs_url(endpoint))aswebsocket:awaitstomp_connect(websocket)print(f'Connected to {WS_URL}')
// Connect to the WebSocketconstclient=newClient({webSocketFactory:()=>newSockJS(`${WS_URL}/w/messages`)});awaitnewPromise(resolve=>{client.onConnect=resolve;client.activate();});console.log(`Connected to ${WS_URL}`);
The code opens a SockJS connection to the /w/messages endpoint on WS_URL and starts a STOMP session
over it.
# Subscribe to the account and transaction channelsaccount_channel=f'/account/{MONITOR_ADDRESS}'channels={account_channel:'id-0',f'/unconfirmed/{MONITOR_ADDRESS}':'id-1',f'/transactions/{MONITOR_ADDRESS}':'id-2',}forchannel,sub_idinchannels.items():awaitstomp_subscribe(websocket,channel,sub_id)print(f'Subscribed to {channel} channel')
// Subscribe to the account and transaction channelsconstaccountChannel=`/account/${MONITOR_ADDRESS}`;constchannels={[accountChannel]:'id-0',[`/unconfirmed/${MONITOR_ADDRESS}`]:'id-1',[`/transactions/${MONITOR_ADDRESS}`]:'id-2'};letconfirmed=false;letresolveRegistered;letresolveDone;constregistered=newPromise(resolve=>{resolveRegistered=resolve;});constdone=newPromise(resolve=>{resolveDone=resolve;});constonMessage=message=>{constbody=JSON.parse(message.body);const{destination}=message.headers;if(accountChannel===destination){const{balance}=body.account;console.log(`Account update: balance=${balance}`);resolveRegistered();if(confirmed)resolveDone();}elseif(destination.includes('/transactions/')){constmessageHash=body.meta.hash.data;console.log(`confirmed: hash=${messageHash.substring(0,16)}...`);if(messageHash.toUpperCase()===transactionHash){console.log(`Transaction ${shortHash}... confirmed`);confirmed=true;}}else{constmessageHash=body.meta.hash.data;if(messageHash.toUpperCase()===transactionHash){console.log('unconfirmed: hash='+`${messageHash.substring(0,16)}...`);}}};for(const[channel,id]ofObject.entries(channels)){client.subscribe(channel,onMessage,{id});console.log(`Subscribed to ${channel} channel`);}
The code subscribes to three address-scoped channels:
account/{address}WS: Notifies of the account's current state when a block involving the account's address is
confirmed.
unconfirmed/{address}WS: Notifies of a transaction involving the account's address when it enters the
unconfirmed pool, waiting to be included in a block.
transactions/{address}WS: Notifies of a transaction involving the account's address when it is included in a
block.
The subscriptions use the IDs id-0, id-1 and id-2, which identify them when the code unsubscribes at the end.
All three channels stay silent until the address is registered, which the next step performs.
# Register the account and confirm it is activeawaitstomp_send(websocket,'/w/api/account/get',json.dumps({'account':MONITOR_ADDRESS}))asyncforframeinstomp_frames(websocket):ifaccount_channel==frame['headers']['destination']:balance=json.loads(frame['body'])['account']['balance']print(f'Account update: balance={balance}')breakprint('Account registered')
// Register the account and confirm it is activeclient.publish({destination:'/w/api/account/get',body:JSON.stringify({account:MONITOR_ADDRESS})});awaitregistered;console.log('Account registered');
To receive notifications on an account's channels, the address must first be registered with the node.
The code sends a w/api/account/getREQ request, which registers the address and also forces the node to
send the account's current state on the account/{address}WS channel.
The code waits for this first account notification, which confirms that the registration is active.
The notification follows the AccountMetaDataPair schema.
The subscription to the account channel stays open for the rest of the run, so the account notification triggered by
the transaction confirmation also appears in the output.
# Announce the transaction and wait for it to confirmprint(f'Announcing transaction {transaction_hash[:16]}...')announce_request=urllib.request.Request(f'{NODE_URL}/transaction/announce',data=json_payload.encode(),headers={'Content-Type':'application/json'},method='POST')withurllib.request.urlopen(announce_request)asresp:result=json.loads(resp.read().decode())if'SUCCESS'==result['message']:confirmed=Falseasyncforframeinstomp_frames(websocket):destination=frame['headers']['destination']body=json.loads(frame['body'])ifaccount_channel==destination:balance=body['account']['balance']print(f'Account update: balance={balance}')ifconfirmed:breakelif'/transactions/'indestination:message_hash=body['meta']['hash']['data']print(f'confirmed: hash={message_hash[:16]}...')ifmessage_hash.upper()==transaction_hash:short_hash=transaction_hash[:16]print(f'Transaction {short_hash}... confirmed')confirmed=Trueelse:message_hash=body['meta']['hash']['data']ifmessage_hash.upper()==transaction_hash:print(f'unconfirmed: hash={message_hash[:16]}...')else:print(f'Transaction rejected: {result["message"]}')
// Announce the transaction and wait for it to confirmconsole.log(`Announcing transaction ${shortHash}...`);constresponse=awaitfetch(`${NODE_URL}/transaction/announce`,{method:'POST',headers:{'Content-Type':'application/json'},body:jsonPayload});constannounceResult=awaitresponse.json();if('SUCCESS'===announceResult.message)awaitdone;elseconsole.log(`Transaction rejected: ${announceResult.message}`);
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.
The code announces the transaction to the /transaction/announcePOST endpoint and checks the result.
If the node rejects it, the code prints the rejection reason and stops.
Otherwise, the code waits for confirmation, printing each message from the subscribed channels.
Messages from the transaction channels follow the
TransactionMetaDataPair schema, whose
meta.hash.data field holds the transaction hash.
As each one arrives, the message handler compares that hash against the stored value to recognize this transaction
among the channel notifications.
The expected sequence for a successful transaction is described in the
Transaction Lifecycle section:
confirmed: The transaction is included in a block.
The block that includes the transaction also triggers a final notification on the account/{address}WS
channel.
Unlike the transaction channels, this notification contains the account's updated state rather than a transaction hash,
so it cannot be matched to a specific transaction.
Once this final notification arrives, the program moves on to the cleanup step.
# Unsubscribe before closingforsub_idinchannels.values():awaitstomp_unsubscribe(websocket,sub_id)print('Unsubscribed from all channels')awaitstomp_disconnect(websocket)
// Unsubscribe before closingfor(constidofObject.values(channels))client.unsubscribe(id);console.log('Unsubscribed from all channels');client.deactivate();
After confirmation, the code unsubscribes from the three channels and ends the STOMP session before the connection
closes.
Using node http://libertalia.nemtest.net:7890
Monitoring address: TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4
Connected to http://libertalia.nemtest.net:7778
Subscribed to /account/TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4 channel
Subscribed to /unconfirmed/TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4 channel
Subscribed to /transactions/TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4 channel
Account update: balance=10035200000
Account registered
Announcing transaction 2928C2D9554AE127...
unconfirmed: hash=2928c2d9554ae127...
confirmed: hash=2928c2d9554ae127...
Transaction 2928C2D9554AE127... confirmed
Account update: balance=10035200000
Unsubscribed from all channels
The output shows:
Address (line 2): The monitored address.
Connection (line 3): The STOMP session is established over the node's WebSocket endpoint at port 7778.
Subscriptions (lines 4-6): The account channel and both transaction channels are subscribed.
Registration (lines 7-8): The account's current state arrives on the account channel, confirming the
registration.
Announcement (line 9): The transaction is announced and its hash is printed.
Transaction flow (lines 10-11): The transaction moves from unconfirmed to confirmed, showing the
confirmation lifecycle.
Confirmation (line 12): The hash from the transactions/{address}WS channel matches the announced
transaction.
Account update (line 13): The block containing the transaction triggers a final account notification.
The balance is unchanged, since the transfer amount is zero.
Unsubscribe (line 14): The code unsubscribes from the three channels.