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 — 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, 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:

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, 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 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 — 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.


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.