Skip to content

Creating a Transfer Transaction⚓︎

BEGINNER

Transfer transactions are the most basic type of Symbol transaction. They allow sending XYM or any other type of mosaic from one account to another, optionally including a message.

This tutorial shows how to create, sign, and announce a transfer transaction, and then poll the transaction's status until it is confirmed. The recommended fee multiplier is fetched from the network so the SDK can calculate an appropriate transaction fee.

This tutorial is used to explain the basic concepts of transaction creation and announcement. The rest of tutorials refer to this one to explain these basic techniques.

Alternative Manual Transaction Creation

This tutorial creates transactions from descriptors, which is the most convenient and type-safe method. For an alternative, lower-level, manual method, see the Manual Transaction Creation tutorial.

Prerequisites⚓︎

Before you start, make sure to:

Full Code⚓︎

The following is the complete code listing for this tutorial. A detailed, step-by-step explanation follows in the next section.

import json
import os
import time
import urllib.request

from symbolchain.CryptoTypes import PrivateKey
from symbolchain.facade.SymbolFacade import SymbolFacade
from symbolchain.symbol.IdGenerator import generate_mosaic_alias_id

NODE_URL = os.getenv('NODE_URL', 'https://reference.symboltest.net:3001')

print(f'Using node {NODE_URL}')

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

facade = SymbolFacade('testnet')


# Helper function to announce a transaction
def announce_transaction(payload, label):
    print(f'Announcing {label} to /transactions')
    request = urllib.request.Request(
        f'{NODE_URL}/transactions',
        data=payload.encode(),
        headers={'Content-Type': 'application/json'},
        method='PUT'
    )
    with urllib.request.urlopen(request) as announce_response:
        print(f'  Response: {announce_response.read().decode()}')


# Helper function to wait for transaction confirmation
def wait_for_confirmation(tx_hash, label):
    print(f'Waiting for {label} confirmation...')
    for attempt in range(60):
        time.sleep(1)
        try:
            url = f'{NODE_URL}/transactionStatus/{tx_hash}'
            with urllib.request.urlopen(url) as confirm_response:
                status = json.loads(confirm_response.read().decode())
                print(f'  Transaction status: {status["group"]}')
                if status['group'] == 'confirmed':
                    print(f'{label} confirmed in {attempt} seconds')
                    return
                if status['group'] == 'failed':
                    raise RuntimeError(
                        f'{label} failed: {status["code"]}')
        except urllib.error.HTTPError:
            print('  Transaction status: unknown')
    raise TimeoutError(f'{label} not confirmed after 60 seconds')


try:
    # Fetch recommended fees
    fee_path = '/network/fees/transaction'
    print(f'Fetching recommended fees from {fee_path}')
    with urllib.request.urlopen(f'{NODE_URL}{fee_path}') as response:
        response_json = json.loads(response.read().decode())
        median_multiplier = response_json['medianFeeMultiplier']
        minimum_multiplier = response_json['minFeeMultiplier']
        fee_multiplier = max(median_multiplier, minimum_multiplier)
        print(f'  Fee multiplier: {fee_multiplier}')

    # Build the transaction
    transaction = facade.create_transaction_from_descriptor(
        {
            'type': 'transfer_transaction_v1',
            'recipient_address':
                facade.network.public_key_to_address(
                    signer_key_pair.public_key),
            'mosaics': [{
                'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                'amount': 1_000_000  # 1 XYM
            }]
        },
        signer_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60)

    # Sign transaction and generate final payload
    signature = facade.sign_transaction(signer_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    print('Built transaction:')
    print(json.dumps(transaction.to_json(), indent=2))

    transaction_hash = facade.hash_transaction(transaction)
    print(f'Transaction hash: {transaction_hash}')
    announce_transaction(json_payload, 'transaction')
    wait_for_confirmation(transaction_hash, 'transaction')
except urllib.error.URLError as e:
    print(e.reason)

Download source

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

const NODE_URL = process.env.NODE_URL ||
    'https://reference.symboltest.net:3001';
console.log('Using node', NODE_URL);

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

const facade = new SymbolFacade('testnet');

// Helper function to announce a transaction
async function announceTransaction(payload, label) {
    console.log(`Announcing ${label} to /transactions`);
    const response = await fetch(`${NODE_URL}/transactions`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: payload
    });
    console.log('  Response:', await response.text());
}


// Helper function to wait for transaction confirmation
async function waitForConfirmation(transactionHash, label) {
    console.log(`Waiting for ${label} confirmation...`);
    for (let attempt = 0; 60 > attempt; attempt++) {
        await new Promise(resolve => { setTimeout(resolve, 1000); });
        const response = await fetch(
            `${NODE_URL}/transactionStatus/${transactionHash}`);
        if (!response.ok) {
            if (404 === response.status) {
                console.log('  Transaction status: unknown');
                continue;
            }
            throw new Error(`HTTP ${response.status}`);
        }
        const status = await response.json();
        console.log('  Transaction status:', status.group);
        if ('confirmed' === status.group) {
            console.log(`${label} confirmed in`, attempt, 'seconds');
            return;
        }
        if ('failed' === status.group)
            throw new Error(`${label} failed: ${status.code}`);
    }
    throw new Error(`${label} not confirmed after 60 seconds`);
}


try {
    // Fetch recommended fees
    const feePath = '/network/fees/transaction';
    console.log('Fetching recommended fees from', feePath);
    const feeResponse = await fetch(`${NODE_URL}${feePath}`);
    const feeJSON = await feeResponse.json();
    const medianMultiplier = feeJSON.medianFeeMultiplier;
    const minimumMultiplier = feeJSON.minFeeMultiplier;
    const feeMultiplier = Math.max(medianMultiplier, minimumMultiplier);
    console.log('  Fee multiplier:', feeMultiplier);

    // Build the transaction
    const transaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.TransferTransactionV1Descriptor(
            facade.network.publicKeyToAddress(signerKeyPair.publicKey),
            [
                new descriptors.UnresolvedMosaicDescriptor(
                    generateMosaicAliasId('symbol.xym'),
                    new models.Amount(1_000_000n)) // 1 XYM
            ],
            undefined),
        signerKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60);

    // Sign transaction and generate final payload
    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    console.log('Built transaction:');
    console.dir(transaction.toJson(), { colors: true });

    const transactionHash =
        facade.hashTransaction(transaction).toString();
    console.log('Transaction hash:', transactionHash);
    await announceTransaction(jsonPayload, 'transaction');
    await waitForConfirmation(transactionHash, 'transaction');
} catch (e) {
    console.error(e.message, '| Cause:', e.cause?.code ?? 'unknown');
}

Download source

//JAVA 21+
//DEPS org.symbol:symbol-sdk:3.3.1

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.List;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import org.symbol.sdk.CryptoTypes;
import org.symbol.sdk.facade.SymbolFacade;
import org.symbol.sdk.symbol.IdGenerator;
import org.symbol.sdk.symbol.KeyPair;
import org.symbol.sdk.symbol.SymbolTransactionFactory;
import org.symbol.sdk.symbol.descriptors.*;
import org.symbol.sdk.symbol.models.*;

public final class Transfer {
    private static final ObjectMapper JSON_MAPPER = new ObjectMapper();

    private static final HttpClient HTTP_CLIENT =
        HttpClient.newHttpClient();

    private final String nodeUrl = System.getenv().getOrDefault(
        "NODE_URL", "https://reference.symboltest.net:3001");

    private final SymbolFacade facade = new SymbolFacade("testnet");

    // Helper method to announce a transaction
    private void announceTransaction(
        final String payload,
        final String label
    ) throws IOException, InterruptedException {
        System.out.printf("Announcing %s to /transactions%n", label);
        final HttpRequest request = HttpRequest.newBuilder(
            URI.create(nodeUrl + "/transactions"))
            .header("Content-Type", "application/json")
            .PUT(HttpRequest.BodyPublishers.ofString(payload))
            .build();
        final HttpResponse<String> response = HTTP_CLIENT.send(
            request, BodyHandlers.ofString());
        System.out.printf("  Response: %s%n", response.body());
    }


    // Helper method to wait for transaction confirmation
    private void waitForConfirmation(
        final String transactionHash,
        final String label
    ) throws IOException, InterruptedException {
        System.out.printf("Waiting for %s confirmation...%n", label);
        for (int attempt = 0; 60 > attempt; ++attempt) {
            Thread.sleep(1000);
            final String statusPath =
                "/transactionStatus/" + transactionHash;
            final HttpRequest statusRequest = HttpRequest.newBuilder(
                URI.create(nodeUrl + statusPath)).GET().build();
            final HttpResponse<String> statusResponse = HTTP_CLIENT
                .send(statusRequest, BodyHandlers.ofString());
            if (404 == statusResponse.statusCode()) {
                System.out.println("  Transaction status: unknown");
                continue;
            }
            if (2 != statusResponse.statusCode() / 100)
                throw new IOException(
                    "HTTP " + statusResponse.statusCode());

            final JsonNode status =
                JSON_MAPPER.readTree(statusResponse.body());
            final String group = status.get("group").asText();
            System.out.printf("  Transaction status: %s%n", group);
            if ("confirmed".equals(group)) {
                System.out.printf("%s confirmed in %d seconds%n",
                    label, attempt);
                return;
            }
            if ("failed".equals(group))
                throw new IOException(String.format("%s failed: %s",
                    label, status.get("code").asText()));
        }
        throw new IOException(String.format(
            "%s not confirmed after 60 seconds", label));
    }


    public static void main(final String[] args) {
        try {
            new Transfer().run();
        } catch (final Exception ex) {
            System.out.println(null == ex.getMessage()
                ? ex.toString()
                : ex.getMessage());
        }
    }

    private void run() throws IOException, InterruptedException {
        System.out.printf("Using node %s%n", nodeUrl);


        final String signerPrivateKey = System.getenv().getOrDefault(
            "SIGNER_PRIVATE_KEY", "0".repeat(64));
        final KeyPair signerKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(signerPrivateKey));


        // Fetch recommended fees
        final String feePath = "/network/fees/transaction";
        System.out.printf("Fetching recommended fees from %s%n", feePath);
        final HttpRequest feeRequest = HttpRequest.newBuilder(
            URI.create(nodeUrl + feePath)).GET().build();
        final HttpResponse<String> feeResponse = HTTP_CLIENT.send(
            feeRequest, BodyHandlers.ofString());
        final JsonNode feeJSON = JSON_MAPPER.readTree(feeResponse.body());
        final long medianMultiplier =
            feeJSON.get("medianFeeMultiplier").asLong();
        final long minimumMultiplier =
            feeJSON.get("minFeeMultiplier").asLong();
        final long feeMultiplier = Math.max(
            medianMultiplier, minimumMultiplier);
        System.out.printf("  Fee multiplier: %d%n", feeMultiplier);

        // Build the transaction
        final Transaction transaction =
            facade.createTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    facade.network.publicKeyToAddress(
                        signerKeyPair.getPublicKey()),
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(1_000_000))), // 1 XYM
                    null),
                signerKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);

        // Sign transaction and generate final payload
        final CryptoTypes.Signature signature = facade.signTransaction(
            signerKeyPair, transaction);
        final String jsonPayload = SymbolTransactionFactory
            .attachSignature(transaction, signature);
        System.out.println("Built transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(transaction.toJson()));

        final String transactionHash =
            facade.hashTransaction(transaction).toString();
        System.out.printf("Transaction hash: %s%n", transactionHash);
        announceTransaction(jsonPayload, "transaction");
        waitForConfirmation(transactionHash, "transaction");
    }
}

Download source

The whole code is wrapped in a single try block to provide simple error handling, but applications will probably want to use more fine-grained control.

Code Explanation⚓︎

Setting Up the Account⚓︎

SIGNER_PRIVATE_KEY = os.getenv('SIGNER_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000')
signer_key_pair = SymbolFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY))
const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY ||
    '0000000000000000000000000000000000000000000000000000000000000000';
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));
        final String signerPrivateKey = System.getenv().getOrDefault(
            "SIGNER_PRIVATE_KEY", "0".repeat(64));
        final KeyPair signerKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(signerPrivateKey));

The signer account is loaded from the SIGNER_PRIVATE_KEY environment variable. If not provided, a test key is used as default.

    # Fetch recommended fees
    fee_path = '/network/fees/transaction'
    print(f'Fetching recommended fees from {fee_path}')
    with urllib.request.urlopen(f'{NODE_URL}{fee_path}') as response:
        response_json = json.loads(response.read().decode())
        median_multiplier = response_json['medianFeeMultiplier']
        minimum_multiplier = response_json['minFeeMultiplier']
        fee_multiplier = max(median_multiplier, minimum_multiplier)
        print(f'  Fee multiplier: {fee_multiplier}')
    // Fetch recommended fees
    const feePath = '/network/fees/transaction';
    console.log('Fetching recommended fees from', feePath);
    const feeResponse = await fetch(`${NODE_URL}${feePath}`);
    const feeJSON = await feeResponse.json();
    const medianMultiplier = feeJSON.medianFeeMultiplier;
    const minimumMultiplier = feeJSON.minFeeMultiplier;
    const feeMultiplier = Math.max(medianMultiplier, minimumMultiplier);
    console.log('  Fee multiplier:', feeMultiplier);
        // Fetch recommended fees
        final String feePath = "/network/fees/transaction";
        System.out.printf("Fetching recommended fees from %s%n", feePath);
        final HttpRequest feeRequest = HttpRequest.newBuilder(
            URI.create(nodeUrl + feePath)).GET().build();
        final HttpResponse<String> feeResponse = HTTP_CLIENT.send(
            feeRequest, BodyHandlers.ofString());
        final JsonNode feeJSON = JSON_MAPPER.readTree(feeResponse.body());
        final long medianMultiplier =
            feeJSON.get("medianFeeMultiplier").asLong();
        final long minimumMultiplier =
            feeJSON.get("minFeeMultiplier").asLong();
        final long feeMultiplier = Math.max(
            medianMultiplier, minimumMultiplier);
        System.out.printf("  Fee multiplier: %d%n", feeMultiplier);

Transactions on Symbol must pay a fee to incentivize nodes to include them in blocks. If the fee is too low, no node may include the transaction. If it is too high, the sender wastes funds. In addition, each node may enforce a minimum fee threshold for incoming transactions.

The optimal fee depends on the current state of the network, particularly the number of transactions being submitted and the fees they are offering. To support fee estimation, Symbol provides the /network/fees/transaction GET endpoint that returns a recommended fee multiplier based on recent transaction activity.

The final fee is calculated by multiplying the recommended multiplier by the transaction's size in bytes. When creating transactions from descriptors as done in all tutorials, this operation is performed by the SDK. If you create transactions manually using , you need to calculate the final fee yourself.

Although applications can use a fixed fee for simplicity, it is more efficient to follow the network recommendation. There is no need to query the multiplier for every transaction, but it should be refreshed regularly.

The snippet above takes the greater of the network's recommended multiplier (medianFeeMultiplier) and the minimum multiplier (minFeeMultiplier) required by the node where the transactions will be sent. The result is stored for later use once the transaction size is known.

Building the Transaction⚓︎

On Descriptors

The transfer transaction is created from the transaction's descriptor.

On typed languages like Java or JavaScript, these descriptors are also typed, so there are less chances of using the wrong parameter when building or using them. On untyped languages like Python, descriptors are generic objects which must contain the right fields for each transaction type.

The descriptor contains the transaction-specific fields, while the creation method receives the common fields used to finish the transaction.

    # Build the transaction
    transaction = facade.create_transaction_from_descriptor(
        {
            'type': 'transfer_transaction_v1',
            'recipient_address':
                facade.network.public_key_to_address(
                    signer_key_pair.public_key),
            'mosaics': [{
                'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                'amount': 1_000_000  # 1 XYM
            }]
        },
        signer_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60)
    // Build the transaction
    const transaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.TransferTransactionV1Descriptor(
            facade.network.publicKeyToAddress(signerKeyPair.publicKey),
            [
                new descriptors.UnresolvedMosaicDescriptor(
                    generateMosaicAliasId('symbol.xym'),
                    new models.Amount(1_000_000n)) // 1 XYM
            ],
            undefined),
        signerKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60);
        // Build the transaction
        final Transaction transaction =
            facade.createTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    facade.network.publicKeyToAddress(
                        signerKeyPair.getPublicKey()),
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(1_000_000))), // 1 XYM
                    null),
                signerKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);

receives:

  • The transaction's descriptor: Defines TransferTransactionV1 and the transfer fields described below.
  • The signer public key: The signer is the account that will pay the fee. In a transfer transaction, it is also the source of the transferred mosaics.
  • The fee multiplier: Used to calculate the transaction fee.
  • The deadline duration: Set to two hours from the current time.

    Deadlines and network time

    Transactions on Symbol must include a deadline, which defines how long the network should attempt to confirm the transaction before discarding it. Deadlines are expressed in network time, measured from the nemesis block.

    If a transaction's deadline is earlier than the current network time or more than six hours in the future, the transaction will be rejected.

    When creating transactions from descriptors, the SDK takes care of network time and accepts a relative deadline duration in seconds from now.

    If you create transactions manually using , you need to provide the absolute deadline yourself, as shown in the Manual Transaction Creation tutorial.

The transaction's descriptor contains:

  • : In this example, the recipient is the same as the sender, which is useful for demonstration but not terribly practical.

  • mosaics: This is an array, because a transfer transaction can send multiple mosaics at once. Each entry includes a mosaic ID and an amount.

    In the example, the mosaic ID for XYM is obtained using its alias, symbol.xym, which is easier to remember than the full hexadecimal ID.

    Amounts are expressed in atomic units, which depend on the mosaic's divisibility. For XYM, the divisibility is 6, so 1 XYM must be expressed as 1_000_000.

The descriptor does not include common transaction fields such as the signer public key, deadline, or fee. fills them in, taking care of network time for the relative deadline and calculating the fee from the fee multiplier.

Including a message in the transaction

Transactions can optionally include a free-form message. The Sending Messages with Transfer Transactions explains how to do this.

Signing and Serializing⚓︎

    # Sign transaction and generate final payload
    signature = facade.sign_transaction(signer_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    print('Built transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    // Sign transaction and generate final payload
    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    console.log('Built transaction:');
    console.dir(transaction.toJson(), { colors: true });
        // Sign transaction and generate final payload
        final CryptoTypes.Signature signature = facade.signTransaction(
            signerKeyPair, transaction);
        final String jsonPayload = SymbolTransactionFactory
            .attachSignature(transaction, signature);
        System.out.println("Built transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(transaction.toJson()));

Once the transaction is created, it must be signed with the signing account's private key. Signing ensures the transaction is authentic and authorized by the sender.

returns a signature.

adds the signature to the transaction and serializes it into a JSON payload ready to be submitted directly to a node for announcement.

Announcing the Transaction⚓︎

# Helper function to announce a transaction
def announce_transaction(payload, label):
    print(f'Announcing {label} to /transactions')
    request = urllib.request.Request(
        f'{NODE_URL}/transactions',
        data=payload.encode(),
        headers={'Content-Type': 'application/json'},
        method='PUT'
    )
    with urllib.request.urlopen(request) as announce_response:
        print(f'  Response: {announce_response.read().decode()}')
// Helper function to announce a transaction
async function announceTransaction(payload, label) {
    console.log(`Announcing ${label} to /transactions`);
    const response = await fetch(`${NODE_URL}/transactions`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: payload
    });
    console.log('  Response:', await response.text());
}
    // Helper method to announce a transaction
    private void announceTransaction(
        final String payload,
        final String label
    ) throws IOException, InterruptedException {
        System.out.printf("Announcing %s to /transactions%n", label);
        final HttpRequest request = HttpRequest.newBuilder(
            URI.create(nodeUrl + "/transactions"))
            .header("Content-Type", "application/json")
            .PUT(HttpRequest.BodyPublishers.ofString(payload))
            .build();
        final HttpResponse<String> response = HTTP_CLIENT.send(
            request, BodyHandlers.ofString());
        System.out.printf("  Response: %s%n", response.body());
    }

The helper receives the JSON payload and a human-readable label. The label is only used in log messages, which makes the same helper reusable in tutorials that announce several transactions.

Announcing a transaction is a simple request to the /transactions PUT endpoint of any Symbol API node. As long as the payload is correctly formed, the request will succeed with an HTTP 200 response.

However, this response does not indicate that the transaction is valid or accepted by the network. Validation, fee checks, and other rules are applied asynchronously after the transaction is received.

To confirm that the transaction is actually accepted and included in a block, its status must be monitored separately, as shown in the next step.

Waiting for Confirmation⚓︎

# Helper function to wait for transaction confirmation
def wait_for_confirmation(tx_hash, label):
    print(f'Waiting for {label} confirmation...')
    for attempt in range(60):
        time.sleep(1)
        try:
            url = f'{NODE_URL}/transactionStatus/{tx_hash}'
            with urllib.request.urlopen(url) as confirm_response:
                status = json.loads(confirm_response.read().decode())
                print(f'  Transaction status: {status["group"]}')
                if status['group'] == 'confirmed':
                    print(f'{label} confirmed in {attempt} seconds')
                    return
                if status['group'] == 'failed':
                    raise RuntimeError(
                        f'{label} failed: {status["code"]}')
        except urllib.error.HTTPError:
            print('  Transaction status: unknown')
    raise TimeoutError(f'{label} not confirmed after 60 seconds')
// Helper function to wait for transaction confirmation
async function waitForConfirmation(transactionHash, label) {
    console.log(`Waiting for ${label} confirmation...`);
    for (let attempt = 0; 60 > attempt; attempt++) {
        await new Promise(resolve => { setTimeout(resolve, 1000); });
        const response = await fetch(
            `${NODE_URL}/transactionStatus/${transactionHash}`);
        if (!response.ok) {
            if (404 === response.status) {
                console.log('  Transaction status: unknown');
                continue;
            }
            throw new Error(`HTTP ${response.status}`);
        }
        const status = await response.json();
        console.log('  Transaction status:', status.group);
        if ('confirmed' === status.group) {
            console.log(`${label} confirmed in`, attempt, 'seconds');
            return;
        }
        if ('failed' === status.group)
            throw new Error(`${label} failed: ${status.code}`);
    }
    throw new Error(`${label} not confirmed after 60 seconds`);
}
    // Helper method to wait for transaction confirmation
    private void waitForConfirmation(
        final String transactionHash,
        final String label
    ) throws IOException, InterruptedException {
        System.out.printf("Waiting for %s confirmation...%n", label);
        for (int attempt = 0; 60 > attempt; ++attempt) {
            Thread.sleep(1000);
            final String statusPath =
                "/transactionStatus/" + transactionHash;
            final HttpRequest statusRequest = HttpRequest.newBuilder(
                URI.create(nodeUrl + statusPath)).GET().build();
            final HttpResponse<String> statusResponse = HTTP_CLIENT
                .send(statusRequest, BodyHandlers.ofString());
            if (404 == statusResponse.statusCode()) {
                System.out.println("  Transaction status: unknown");
                continue;
            }
            if (2 != statusResponse.statusCode() / 100)
                throw new IOException(
                    "HTTP " + statusResponse.statusCode());

            final JsonNode status =
                JSON_MAPPER.readTree(statusResponse.body());
            final String group = status.get("group").asText();
            System.out.printf("  Transaction status: %s%n", group);
            if ("confirmed".equals(group)) {
                System.out.printf("%s confirmed in %d seconds%n",
                    label, attempt);
                return;
            }
            if ("failed".equals(group))
                throw new IOException(String.format("%s failed: %s",
                    label, status.get("code").asText()));
        }
        throw new IOException(String.format(
            "%s not confirmed after 60 seconds", label));
    }

Note

This step uses polling to check whether the transaction has been confirmed. Polling is used here for illustration purposes, but it is not the recommended approach for real applications.

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

In addition, the logic for checking transaction status is reusable. This tutorial defines it as a helper because it is needed after announcing almost every transaction.

The snippet above repeatedly queries the /transactionStatus/{hash} GET endpoint using the hash of the submitted transaction. Like the announcement helper, it receives a label so its output remains clear when a tutorial announces several transactions. The response may take one of several forms:

  • An HTTP error, indicating that the node has not yet started processing the transaction.
  • A valid JSON object containing the transaction status.

If the status group is confirmed, the transaction has been accepted and included in a block.

If the status group is failed, the transaction has been rejected, for example, due to insufficient funds.

In any other case, the code waits one second and tries again, up to a maximum of 60 times.

Output⚓︎

The output shown below corresponds to a typical run of the program.

Using node https://reference.symboltest.net:3001
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Built transaction:
{
  signature: '728D968E14F50EBB2496B560721E938629D6B4C1522B4A22DD659507B469C0EC5125485EBD38D48FBF351FF9DEC9CF3AFD7A5AFC5E945087E53173589B0B6B08',
  signerPublicKey: '87DA603E7BE5656C45692D5FC7F6D0EF8F24BB7A5C10ED5FDA8C5CFBC49FCBC8',
  version: 1,
  network: 152,
  type: 16724,
  fee: '17600',
  deadline: '78242662065',
  recipientAddress: '98F96BD2F803DE1EE39AACFC53A246F4F7A46901A5D0A53E',
  mosaics: [ { mosaicId: '16666583871264174062', amount: '1000000' } ],
  message: ''
}
Transaction hash: 260CD293E05C2853A967874BCF67FAB36FD331CE14925CA611B3877B99BB325D
Announcing transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for transaction confirmation...
  Transaction status: unknown
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: confirmed
transaction confirmed in 5 seconds

Some highlights from the output:

  • Fee multiplier (line 3): The recommended multiplier fetched from the network, used together with the transaction size to compute the fee.

  • Signer public key (line 7): The account that signs the transaction and sends the mosaics.

  • Transaction fee (line 11): 17600 atomic units (0.0176 XYM), derived from the fee multiplier and the transaction's size in bytes.

  • Recipient address (line 13): The account that receives the mosaics. It looks different from the address used in the code because the transaction format encodes it in its raw hexadecimal form rather than the Base32 text.

  • Mosaics (line 14): The assets transferred. Here, 1000000 atomic units of the mosaic aliased by symbol.xym (XYM), equal to 1 XYM.

  • Announcement response (line 19): The node accepted the payload. This does not yet mean the transaction is valid or included in a block.

  • Confirmed status (line 26): The transaction has been accepted and included in a block.

The number of status checks before confirmation can vary based on network conditions, and the initial unknown status may or may not appear, depending on how quickly the node begins processing the transaction.

To see the transaction from the network's perspective, you can visit the Symbol Testnet Explorer and search for the transaction hash. The hash is printed in the line that starts with Transaction hash:. You should see the transaction move through the confirmation process in real time.

Alternatively, you can search for the signerPublicKey to view the transaction in the history of the signer account.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Obtain fee information /network/fees/transaction GET
Build a transaction , TransferTransactionV1
Sign the transaction
Announce the transaction /transactions PUT
Wait for confirmation /transactionStatus/{hash} GET

Other transaction types follow the same general process.