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 payloadsPOST /api/v1/transactions/json— for structured JSON objects only, e.g.{ "event": "invoice_paid", "amount": 1500 }, while scalars and arrays are rejected with a400
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:
| Flow | Metadata flag | What the TMS returns | How you learn the result |
|---|---|---|---|
| Direct | { "useDirectFlow": "true" } | The full transaction synchronously | In the HTTP response |
| Async (queue) | { "useAsyncFlow": "true" } | A messageId acknowledgement | Poll 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 returnedmessageIduntil it reports atransactionId. - 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 atransactionkey alongside an optionalmetadatablock (decodedOutputResult,nodeIdQueried). Handle both — readtransactionResult.transactionwhen present, otherwise treattransactionResultas the transaction itself.- The transaction carries a required
transactionId, an optionalblockchainId, and an optionalstatusofSUBMITTED,ACCEPTED, orREJECTED. 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
}
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 see | What it means |
|---|---|
200, status: SUBMITTED, empty output | Handed to a node. Outcome unknown. |
Lookup returns the transaction, output empty | Pending. Poll again. |
Lookup returns the transaction, output: SUCCESS | Confirmed. |
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 passed | Not 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 foundand the TMS lookup can answer an error such as500. 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.