Skip to main content

Transactions

Transactions are the building material of the blockchain. Every payload you submit gets signed by your wallet and stored into blocks which then are shared across every node in the network creating the immutable ledger.


Registering your wallet — the first transaction​

Before you can submit any transaction, your wallet needs to be registered on the blockchain, which is a transaction by itself.

Do this once per wallet per blockchain.

const res = await fetch('https://your-tms-url/api/v1/transactions/register-wallet', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
walletAddress: 'your-wallet-address',
blockchain: '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2',
passphrase: 'my-secure-passphrase', // omit if wallet has no passphrase
}),
});

const result = await res.json();
console.log('Wallet registered:', result);

After this the wallet is live on the chain and ready to use.


Submitting a transaction​

With the wallet registered, you can write anything to the blockchain. The TMS picks an available node from your access key's allowed nodes and forwards the transaction taking care of the routing and queuing for you.

const res = await fetch('https://your-tms-url/api/v1/transactions', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
blockchainId: '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2',
from: 'your-wallet-address',
to: 'your-wallet-address',
walletAddress: 'your-wallet-address',
passphrase: 'my-secure-passphrase',
payload: 'anything you want to store permanently',
payloadType: 'text',
metadata: {
source: 'my-app',
version: '1.0',
},
}),
});

const tx = await res.json();
console.log('Transaction ID:', tx.transactions.transactionId);

If you know upfront that your payload is structured data, use the typed endpoints instead because they make intent clearer and handle serialization correctly:

  • POST /api/v1/transactions/text — for plain string payloads
  • POST /api/v1/transactions/json — for structured JSON objects only, e.g. { "event": "invoice_paid", "amount": 1500 }, while scalars and arrays are rejected with a 400

Both accept the same fields as the generic endpoint.

:::warning Send large integers as strings

JSON numbers are handled as IEEE-754 doubles, so any integer larger than 2^53 − 1 (9007199254740991) is rounded when it is stored. The write still succeeds, you get a 200 and a committed transaction, but the value on chain is not the one you sent:

// sent
{ "orderId": 1234567890123456789 }

// stored
{ "orderId": 1234567890123456800 }

This affects the values most likely to appear in a payload: 19-digit IDs, nanosecond timestamps, and token amounts in base units (one whole token at 18 decimals is 10^18).

Send large integers as strings, { "orderId": "1234567890123456789" }, and they round-trip exactly. Values at or below 2^53 − 1 are unaffected. :::


Choosing a submission flow​

When you submit a transaction the TMS can process it in one of two flows. You pick the flow per-request through the metadata field:

FlowMetadata flagWhat the TMS returnsHow you learn the result
Direct{ "useDirectFlow": "true" }The full transaction synchronouslyIn the HTTP response
Async (queue){ "useAsyncFlow": "true" }A messageId acknowledgementPoll the queue endpoint, or receive a webhook

Both flags are strings and not booleans, so send "true" and not true.

Direct flow​

The TMS submits the transaction to a node and waits, returning the finalized transaction in the same response. Simplest to consume because there's nothing to poll or listen for, yet the request stays open until the node responds, so it's best for low-to-moderate volume where you want the result inline.

const res = await fetch('https://your-tms-url/api/v1/transactions', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
blockchainId: '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2',
from: 'your-wallet-address',
to: 'your-wallet-address',
walletAddress: 'your-wallet-address',
payload: JSON.stringify({ event: 'invoice_paid', amount: 1500 }),
payloadType: 'DATA',
metadata: { useDirectFlow: 'true' },
}),
});

const { transaction } = await res.json();
console.log('Transaction ID:', transaction.transactionId);

Async (queue) flow​

The TMS enqueues the transaction and immediately returns a messageId, freeing your request. The transaction is processed in the background. This is the flow to reach for under high throughput or when you're submitting from a worker that shouldn't block on node latency.

const res = await fetch('https://your-tms-url/api/v1/transactions', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
blockchainId: '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2',
from: 'your-wallet-address',
to: 'your-wallet-address',
walletAddress: 'your-wallet-address',
payload: JSON.stringify({ event: 'invoice_paid', amount: 1500 }),
payloadType: 'DATA',
metadata: { useAsyncFlow: 'true' },
}),
});

const { messageId } = await res.json();
console.log('Queued as:', messageId);

You then find out the result in one of two ways:

  • Poll — call GET /api/v1/transactions/{blockchainId}/queue/{msgId} with the returned messageId until it reports a transactionId.
  • Webhook — register a webhook and let the TMS notify you when the transaction confirms (see below). This is the recommended approach because there is no polling loop and you react the moment the transaction lands.

Receiving webhook confirmations (async flow)​

In the async flow the TMS calls back to your endpoint once the queued transaction is resolved. The callback body ties the original messageId to the finalized transaction:

{
"messageId": "msg-abc123",
"transactionResult": {
"transaction": {
"transactionId": "a1b2c3d4e5f6...",
"blockchainId": "08c28f29...",
"status": "ACCEPTED"
},
"metadata": {
"decodedOutputResult": "...",
"nodeIdQueried": "your-node-id"
}
},
"timestamp": 1716663600
}

The payload fields:

  • messageId — the same ID the async submission returned. Match on it to reconcile the callback with the transaction you queued.
  • transactionResult — comes in one of two shapes: either the transaction object directly, or an object wrapping it under a transaction key alongside an optional metadata block (decodedOutputResult, nodeIdQueried). Handle both — read transactionResult.transaction when present, otherwise treat transactionResult as the transaction itself.
  • The transaction carries a required transactionId, an optional blockchainId, and an optional status of SUBMITTED, ACCEPTED, or REJECTED.
  • timestamp — Unix seconds when the TMS emitted the callback.

Verifying the signature​

Every webhook carries an x-tms-signature header so you can confirm the request genuinely came from the TMS and wasn't replayed. The header has the form:

t=1716663600,v1=5257a869e7...
  • t — the Unix timestamp (in seconds) when the signature was generated.
  • v1 — an HMAC-SHA256 of `${t}.${rawBody}` using your shared webhook secret, hex-encoded.

To verify, recompute the HMAC over the exact raw request body (before JSON parsing) and compare it to v1 using a constant-time comparison. Reject the request if the signature doesn't match, or if the timestamp is outside a tolerance window (5 minutes is a sensible default) to guard against replays.

function verifyWebhookSignature(
rawBody: string,
signatureHeader: string | null,
secret: string,
toleranceMs = 5 * 60 * 1000,
): boolean {
if (!secret || !signatureHeader) return false;

const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => part.split('=', 2)),
);
const ts = parts.t;
const v1 = parts.v1;
if (!ts || !v1) return false;

const timestampMs = Number(ts) * 1000;
if (!Number.isFinite(timestampMs)) return false;
if (Math.abs(Date.now() - timestampMs) > toleranceMs) return false; // expired

const hasher = new Bun.CryptoHasher('sha256', secret);
hasher.update(`${ts}.${rawBody}`);
const expected = hasher.digest('hex');

return safeEqual(expected, v1); // constant-time compare
}
warning

Verify against the raw request body, exactly as received. Parsing to JSON and re-serializing can change whitespace or key order and will break the signature.

A minimal handler reads the raw body, verifies the signature, then confirms the event:

export async function webhook(req: Request): Promise<Response> {
const rawBody = await req.text();
const signature = req.headers.get('x-tms-signature');

if (!verifyWebhookSignature(rawBody, signature, config.tms.webhookSecret)) {
return new Response('invalid signature', { status: 401 });
}

const { messageId, transactionResult } = JSON.parse(rawBody);
const transaction =
'transaction' in transactionResult
? transactionResult.transaction
: transactionResult;

// Reconcile messageId → transactionId in your own store.
await confirmEvent(
messageId,
transaction.transactionId,
transaction.blockchainId ?? null,
);

return new Response(null, { status: 200 });
}

Return 200 once you've stored the result. Any non-2xx response signals the TMS that delivery failed.


Retrieving a transaction​

Once submitted, verify the transaction made it in:

const blockchainId = '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2';
const transactionId = 'your-transaction-id';

const res = await fetch(
`https://your-tms-url/api/v1/transactions/${blockchainId}/${transactionId}`,
{
headers: {
'X-AccessKey': 'your-entity-access-key',
'Accept': 'application/json',
},
}
);

const confirmed = await res.json();
console.log('Status:', confirmed.status);
console.log('Payload:', confirmed.payload);

Transactions in ULedger are incredibly fast. Go check on the transactions immediately after submitting and see how quickly they get confirmed.

:::warning Always confirm: "submitted" does not mean "accepted"

A 200 from any write endpoint means the TMS handed your transaction to a node. It does not mean the network accepted it. The response already contains a transactionId, a blockHeight and status: "SUBMITTED", and none of them confirms the outcome: the height is where the chain stood when the transaction was submitted. A transaction the network then refuses (an unregistered wallet, a missing permission, a key type that does not match the chain, a validation rule) is dropped. It never reaches a block, and nothing is pushed to you.

A transaction is confirmed only when the lookup returns it with a blockHeight and output is SUCCESS. Read what you get back like this:

What you seeWhat it means
200, status: SUBMITTED, empty outputHanded to a node. Outcome unknown.
Lookup returns the transaction, output emptyPending. Poll again.
Lookup returns the transaction, output: SUCCESSConfirmed.
Lookup returns the transaction, output is an error (for example TRANSACTION_ERROR)Committed, but it failed. Read the error.
Lookup does not return it once the wait window has passedNot accepted. Treat it as failed, not as pending.

status is not the outcome. It stays ACCEPTED whether the transaction succeeded or failed. Read output.

Poll the lookup with a deadline instead of assuming success:

async function confirmTransaction(blockchainId: string, transactionId: string, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(
`https://your-tms-url/api/v1/transactions/${blockchainId}/${transactionId}`,
{ headers: { 'X-AccessKey': 'your-entity-access-key', Accept: 'application/json' } },
);
if (res.ok) {
const tx = await res.json();
if (tx.blockHeight && tx.output === 'SUCCESS') return tx; // confirmed
if (tx.output && tx.output !== 'SUCCESS') { // committed, but failed
throw new Error(`transaction failed: ${tx.output}`);
}
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`transaction ${transactionId} was not confirmed within ${timeoutMs / 1000}s`);
}

Two more things to keep in mind:

  • A lookup that returns nothing is not "still pending". For a transaction the network refused, the node answers 404 transaction not found and the TMS lookup can answer an error such as 500. After the wait window, treat it as not accepted.
  • Look it up before you retry. A gateway timeout (504) on submit does not prove the transaction failed, since it may have been accepted. Retrying without looking it up can write the same data twice.

If you talk to a node directly, GET {node}/blockchains/{blockchainId}/transactions/{transactionId}/status returns a state of committed (with blockHeight), pending, rejected or unknown. Only committed is a confirmation. Ask the node that received the transaction. It keeps the reason for a refusal in its own memory for a limited time, so that node answers rejected with a reason (for example rejected by unauthorized wallet, rejected due to unexisting wallet, rejected due to disabled wallet), while every other node answers unknown. The submit response itself does not carry the verdict. Through the TMS you do not choose the node, so use the lookup above. :::


Blocks and the ledger​

Transactions get packed into blocks with up to 200 per block. Once a block is sealed it's distributed across all nodes in your network. That's the immutable record forming in real time.

Read a block to see everything that landed in it:

const res = await fetch('https://your-tms-url/api/v1/nodes/blockchain-block', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
nodeId: 'your-node-id',
blockchain: '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2',
blockHeight: 2301,
}),
});

const block = await res.json();
console.log('Transactions in block:', block.transactions.length);
block.transactions.forEach(tx => console.log(tx.transactionId));

That's the ledger. Wallets sign it, transactions fill it, blocks seal it, nodes hold it.

Browsing transaction history​

The TMS keeps a record of every transaction your entity has submitted. Query it to audit activity, debug a failed flow, or build a dashboard:

const params = new URLSearchParams({
status: 'confirmed',
blockchain_id: '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2',
page: '0',
pageSize: '20',
});

const res = await fetch(
`https://your-tms-url/api/v1/transactions/history?${params}`,
{
headers: {
'X-AccessKey': 'your-entity-access-key',
'Accept': 'application/json',
},
}
);

const history = await res.json();
console.log(`${history.total} transactions found`);
history.items.forEach(tx => console.log(tx.transactionId, tx.status));

Available filters: status, flow_type, blockchain_id, from, to, page, pageSize. All are optional, so omit them to return everything.

If you have a specific internal record ID, fetch it directly at GET /api/v1/transactions/history/{id}.


Up Next​

Now we can go explore smart contracts. Head to Smart Contracts to see how to deploy and interact with smart contracts on ULedger.