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 — 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, e.g.{ "event": "invoice_paid", "amount": 1500 }
Both accept the same fields as the generic endpoint.
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, not booleans — "true", 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 — there's nothing to poll or listen for — but 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 — 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.
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 — 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.