When a transaction occurs on a blockchain, it is rarely a single action. An EVM transaction might transfer ETH, pay a gas fee, trigger a token approval, and call a smart contract - all in one atomic operation. If your system only tracks "money moved from A to B," you are missing most of what happened.
Vilna breaks every transaction into individual canonical events - one event per meaningful action. This page explains the event model, what each event type means, how notifications work, and how to build on top of them. For real-world examples of event monitoring in action, see the use cases.
A blockchain transaction is a container. Inside it, multiple things happen: value moves, fees are paid, permissions are granted, contracts are called. Vilna parses each transaction and extracts every discrete action into a separate event object.
Each event has:
kind- the type of action (transfer,utxo_transfer,fee,approval,call,contract_create)sequence- a 0-based position within the transaction, so you know the order of operationsdata- a payload specific to the event kind, containing addresses, amounts, and asset identifiers
A simple ETH transfer produces two events: a transfer and a fee. A DeFi swap might produce five or more: a call to the router contract, multiple transfer events as tokens move through liquidity pools, and a fee event for gas.
This granularity means your system can react to exactly what happened, not just approximate it.
Vilna normalizes all on-chain activity into canonical event types. Regardless of which blockchain a transaction occurs on, the events always follow the same structure.
Assets moved from one address to another.
| Field | Description |
|---|---|
asset_gid | CAIP-19 identifier of the transferred asset |
from | Sender address |
to | Recipient address |
amount | Transfer amount (base + formatted) |
source | Origin of the transfer: native (chain-level), contract (token contract), or internal (internal transaction) |
token_id | Token identifier for NFTs (ERC-721/ERC-1155), if applicable |
Example scenario: A user sends 1,000 USDT to a merchant wallet. Vilna emits a transfer event with the sender, recipient, token, and exact amount. Your deposit detection system matches the recipient to a customer and credits their account.
{
"kind": "transfer",
"asset_gid": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7",
"from": "0x742d35cc6634c0532925a3b844Bc9e7595f7B123",
"to": "0x8ba1f109551bd432803012645ac136c0d1e5c400",
"amount": { "base": "1000000000", "formatted": "1000.0" },
"source": "contract"
}A UTXO-based transfer with full input/output visibility. Used for Bitcoin and other UTXO chains instead of transfer, providing complete details of all funding sources and all destinations in a single event.
| Field | Description |
|---|---|
asset_gid | CAIP-19 identifier of the transferred asset |
inputs | Array of transaction inputs, each with address, amount (base + formatted), and index |
outputs | Array of transaction outputs, each with address, amount (base + formatted), and index |
fee | Transaction fee (base + formatted) |
Example scenario: A user consolidates two UTXOs into a single payment. The utxo_transfer event shows both input addresses (funding sources), the recipient output, the change output, and the miner fee - all in one event.
{
"kind": "utxo_transfer",
"asset_gid": "bip122:000000000019d6689c085ae165831e93/slip44:0",
"inputs": [
{ "address": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", "amount": { "base": "50000", "formatted": "0.0005" }, "index": 0 },
{ "address": "bc1q5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8", "amount": { "base": "30000", "formatted": "0.0003" }, "index": 1 }
],
"outputs": [
{ "address": "bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3s7p", "amount": { "base": "70000", "formatted": "0.0007" }, "index": 0 },
{ "address": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", "amount": { "base": "8000", "formatted": "0.00008" }, "index": 1 }
],
"fee": { "base": "2000", "formatted": "0.00002" }
}This structure is important for compliance workflows - AML screening requires checking all input addresses (funding sources), not just a single sender.
A network fee was paid to process the transaction.
| Field | Description |
|---|---|
asset_gid | CAIP-19 identifier of the fee asset (always the chain's native currency) |
payer | Address that paid the fee |
amount | Fee amount (base + formatted) |
Example scenario: Every Ethereum transaction costs gas. Your accounting system needs to track fee expenditure separately from transfers to calculate true cost per transaction. The fee event gives you the exact amount paid, without manual calculation.
{
"kind": "fee",
"asset_gid": "eip155:1/slip44:60",
"payer": "0x742d35cc6634c0532925a3b844Bc9e7595f7B123",
"amount": { "base": "2100000000000000", "formatted": "0.0021" }
}A token spending permission was granted or changed.
| Field | Description |
|---|---|
asset_gid | CAIP-19 identifier of the approved token |
owner | Address granting the permission |
spender | Address receiving the permission to spend |
amount | Approved spending limit (base + formatted) |
token_id | Token identifier for NFT approvals (ERC-721/ERC-1155), if applicable |
Example scenario: A user approves a DEX router to spend their USDT. For a custodial platform, this is a security-critical event - someone just authorized a third party to move tokens from a monitored address. Your compliance system should flag and review unlimited approvals.
{
"kind": "approval",
"asset_gid": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7",
"owner": "0x742d35cc6634c0532925a3b844Bc9e7595f7B123",
"spender": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
"amount": { "base": "115792089237316195423570985008687907853269984665640564039457584007913129639935", "formatted": "unlimited" }
}A smart contract function was executed.
| Field | Description |
|---|---|
caller | Address that initiated the call |
target | Contract address that was called |
Example scenario: A monitored address interacts with an unknown smart contract. The call event tells your risk system that a contract interaction occurred, even if no tokens moved directly. This is important for detecting phishing contracts, tracking DeFi positions, and maintaining complete audit trails.
{
"kind": "call",
"caller": "0x742d35cc6634c0532925a3b844Bc9e7595f7B123",
"target": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"
}A new smart contract was deployed.
| Field | Description |
|---|---|
creator | Address that deployed the contract |
created_address | Address of the newly deployed contract |
Example scenario: One of your monitored addresses deploys a new smart contract. For compliance teams, contract deployment is a significant action that should be logged and reviewed. The contract_create event captures exactly who deployed what and where.
{
"kind": "contract_create",
"creator": "0x742d35cc6634c0532925a3b844Bc9e7595f7B123",
"created_address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"
}Many blockchain monitoring solutions only track token transfers. Vilna tracks all canonical event types because real-world use cases demand it.
An approval event means someone authorized a third party to spend tokens from a monitored address. For a crypto bank, exchange, or custodial wallet, this is a security-critical event. If an attacker gains access to one of your addresses and grants themselves an unlimited token approval, a transfer-only system would not notice until the tokens are actually moved - by which time it is too late. Monitoring approvals gives you an early warning window.
Regulators expect complete audit trails. A transfer-only log misses contract interactions that move value in indirect ways - liquidity pool deposits, staking operations, and DAO governance calls all affect your exposure without producing a simple "A sent X to B" event. The call and contract_create events fill these gaps.
In DeFi, value moves through smart contract calls, not just simple transfers. A user might call a swap router that then triggers three internal transfers across two liquidity pools. Without the call event, you see tokens appearing and disappearing with no explanation. With it, you have the full context: which contract was called, which address initiated it, and what transfers resulted.
Different blockchains represent the same actions differently. Ethereum uses ERC-20 Transfer events; Tron uses TRC-20 events; Bitcoin uses UTXO inputs and outputs. Vilna normalizes everything into the same canonical event types. Your code handles an Ethereum transfer and a Bitcoin transfer with the same approach - consistent field naming and predictable structure across chains.
A single transaction can contain many events. Consider a user swapping tokens on a DEX:
call- the user calls the swap router contracttransfer- the user's input tokens move to the liquidity pooltransfer- the output tokens move from the pool to the userfee- the gas fee is paid
If you only tracked transfers, you would see tokens coming and going but miss the contract interaction that caused them. You would also miss the fee, making it impossible to calculate the true cost of the operation.
Different blockchains have different capabilities. Here is what Vilna tracks for each chain family:
| Capability | EVM | Bitcoin | TRON | Solana |
|---|---|---|---|---|
transfer | Native + ERC-20 tokens | N/A | Native + TRC-20 tokens | Native + SPL tokens |
utxo_transfer | N/A | BTC (full UTXO inputs/outputs) | N/A | N/A |
fee | Gas fees | Miner fees | Bandwidth/energy fees | Transaction fees |
approval | ERC-20 approvals | N/A | TRC-20 approvals | N/A |
call | Smart contract calls | N/A | Smart contract calls | Program interactions |
contract_create | Contract deployment | N/A | Contract deployment | Program deployment |
EVM chains (Ethereum, Polygon, Arbitrum, BSC, etc.) support transfer, fee, approval, call, and contract_create, including internal transactions and ERC-721/ERC-1155 NFT events.
Bitcoin supports utxo_transfer and fee events. Bitcoin does not have smart contracts in the EVM sense, so approval, call, and contract_create do not apply. Bitcoin uses utxo_transfer instead of transfer to provide full visibility into all inputs (funding sources) and outputs (destinations) - this is essential for AML compliance and accurate accounting.
TRON is similar to EVM - it supports transfer, fee, approval, call, and contract_create for TRC-20 tokens and smart contract interactions.
Solana supports transfer and fee events, with program-level interaction tracking for call events.
The complete and current list of supported chains and their capabilities is available via GET /blockchains in the Platform API.
When a transaction touches one of your monitored addresses, Vilna sends notifications at specific lifecycle stages. Understanding this lifecycle is essential for building reliable systems.
- A transaction is included in a block on the blockchain.
- Vilna detects the block, parses every transaction, and extracts canonical events.
- Vilna checks if any monitored addresses are involved in the transaction's events or activity.
- If a match is found, a
transaction_detectednotification is sent via all configured channels (webhook and Telegram). The transaction's block status isprocessed(detected but not yet confirmed). - As new blocks are added on top, the transaction accumulates confirmations.
- When the required confirmation count is reached, a
transaction_confirmednotification is sent. The block status is nowconfirmed. - If a blockchain reorganization occurs (rare, but possible), the block status transitions to
reorged. The block is reprocessed from the new chain state.
Notifications are sent at two key moments, and your system should handle them differently:
| Event type | Block status | Speed | Finality | Use for |
|---|---|---|---|---|
transaction_detected | processed | Fast (seconds after block) | Not final - could be reorged | UI updates, provisional display, alerting humans |
transaction_confirmed | confirmed | Slower (minutes, depends on chain) | Final - safe to act on | Crediting accounts, triggering business logic, updating balances |
Design your system around this distinction. Show detected transactions in your UI immediately so users see responsive feedback. But do not credit accounts, trigger payouts, or update ledgers until you receive the confirmation notification. This two-phase approach gives you both speed and safety.
Show detected alerts (block status processed) in your UI for responsiveness, but never credit accounts, trigger payouts, or update ledgers until you receive a confirmed alert (block status confirmed).
When a notification fires, your webhook receives a TransactionAlertPayload - a self-contained JSON object with everything you need to process the event without making additional API calls.
The payload has four top-level fields:
| Field | Description |
|---|---|
event_id | UUIDv5 matching the X-Webhook-Event-Id header. Stable across retries — use it as your deduplication key. |
item | The full transaction object, including all events and activity records |
references | Lookup maps for tokens, blockchains, and addresses referenced in the transaction |
is_test_message | true if sent by the test action, false for real blockchain events |
Here is a realistic webhook payload for a 1,000 USDT transfer on Ethereum. The transaction contains two events (the token transfer and the gas fee) and one activity record showing the net impact on the monitored address:
{
"event_id": "f47ac10b-58cc-5372-a567-0e02b2c3d479",
"item": {
"chain_gid": "eip155:1",
"txid": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"block_number": 19500000,
"is_success": true,
"confirmed_at": "2025-03-15T10:30:00Z",
"events": [
{
"txid": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"sequence": 0,
"kind": "transfer",
"data": {
"kind": "transfer",
"asset_gid": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7",
"from": "0x742d35cc6634c0532925a3b844Bc9e7595f7B123",
"to": "0x8ba1f109551bd432803012645ac136c0d1e5c400",
"amount": { "base": "1000000000", "formatted": "1000.0" },
"source": "contract"
}
},
{
"txid": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"sequence": 1,
"kind": "fee",
"data": {
"kind": "fee",
"asset_gid": "eip155:1/slip44:60",
"payer": "0x742d35cc6634c0532925a3b844Bc9e7595f7B123",
"amount": { "base": "2100000000000000", "formatted": "0.0021" }
}
}
],
"activity": [
{
"chain_gid": "eip155:1",
"txid": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"address": "0x8ba1f109551bd432803012645ac136c0d1e5c400",
"asset_gid": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7",
"direction": "in",
"delta": { "base": "1000000000", "formatted": "1000.0" },
"created_at": "2025-03-15T10:30:00Z"
}
]
},
"references": {
"tokens": {
"eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7": {
"gid": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7",
"name": "Tether USD",
"symbol": "USDT",
"decimals": 6
},
"eip155:1/slip44:60": {
"gid": "eip155:1/slip44:60",
"name": "Ethereum",
"symbol": "ETH",
"decimals": 18
}
},
"blockchains": {
"eip155:1": {
"gid": "eip155:1",
"name": "ethereum",
"short_name": "eth"
}
},
"addresses": {
"0x8ba1f109551bd432803012645ac136c0d1e5c400": "merchant_wallet"
}
},
"is_test_message": false
}Key things to notice:
- The
eventsarray contains every canonical event in the transaction. You can filter bykindto process only the event types you care about. - The
activityarray shows the net balance impact on your monitored addresses. Each entry tells you which address was affected, which asset changed, the direction (inorout), and the exact delta. - The
referencesobject lets you resolve token symbols, names, decimals, and blockchain metadata without calling the API. Everything you need for display and processing is in the payload.
Vilna delivers notifications natively to webhooks and Telegram. But a webhook is just an HTTP POST - you can route that data anywhere your architecture needs it.
Message queues - Point your webhook URL at a lightweight proxy that enqueues messages into RabbitMQ, AWS SQS, or Google Pub/Sub. This decouples event ingestion from processing and gives you backpressure handling for free.
Slack / Discord - Your webhook handler can reformat the payload and forward it to Slack or Discord incoming webhook URLs. This is useful for operations teams who want human-readable transaction alerts in their chat channels.
PagerDuty / OpsGenie - Forward high-value events (large transfers, unexpected approvals, contract deployments) to alerting systems for on-call escalation.
Custom backends - Any system that accepts HTTP POST can receive Vilna events. Point the webhook at your internal API, a serverless function, or a data pipeline ingestion endpoint.
Native adapters for Slack, Discord, and popular message queues are in development. If you need a specific integration, contact support@vilna.io.
When Vilna sends a webhook notification, it expects your endpoint to return a 2xx status code within 10 seconds. A delivery is considered failed if the connection cannot be established, the response times out, or the endpoint returns a 4xx/5xx status code.
Failed deliveries are retried on the following schedule:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
If all five attempts fail, the event is marked as undelivered. A single undelivered event does not disable the channel - transient failures are expected. However, if an endpoint fails repeatedly across multiple events, Vilna disables the channel to stop wasting resources. A 410 Gone response immediately disables the channel on the first attempt.
Delivery ordering: Notifications are dispatched in the order events occur, but delivery is not strictly ordered. If your endpoint is slow to respond, a later event may arrive before an earlier retry completes. Use block_number and sequence from the payload to reconstruct the correct order on your side.
A well-built webhook handler is reliable, idempotent, and fast. Follow these practices to avoid common pitfalls.
Your webhook endpoint should return HTTP 2xx as fast as possible. Enqueue the payload for asynchronous processing rather than doing heavy work in the request handler.
Return HTTP 200 immediately and enqueue the payload for async processing. Heavy work in the request handler risks timeouts and missed deliveries.
Every delivery carries an X-Webhook-Signature header in Stripe format (t=<unix-seconds>,v1=<hex>) with an HMAC-SHA256 of the raw request body. Verify it before processing — both to confirm the payload came from Vilna and to reject replays via the timestamp inside the header. See Authentication — Webhook signature verification for the verification function and required client behaviors.
Use event_id from the body (or X-Webhook-Event-Id from the headers — they are the same value) to detect duplicate deliveries. Store processed IDs in a database or cache and skip any you have already seen. This is critical for financial operations — you never want to credit an account twice for the same transaction.
If your system only cares about transfers, filter the events array by kind and ignore the rest. For EVM/TRON/Solana chains, look for transfer; for Bitcoin, look for utxo_transfer. This keeps your processing logic focused:
const transfers = payload.item.events.filter(
(event) => event.kind === "transfer" || event.kind === "utxo_transfer"
);
for (const event of transfers) {
if (event.kind === "utxo_transfer") {
// Bitcoin: full UTXO inputs/outputs
await processUtxoTransfer(event.data);
} else {
// EVM/TRON/Solana: single from→to transfer
await processTransfer(event.data);
}
}The references field contains token metadata (name, symbol, decimals) and blockchain information for every asset and chain mentioned in the transaction. Use this data directly instead of making extra API calls:
const tokenGid = event.data.asset_gid;
const token = payload.references.tokens[tokenGid];
console.log(`Received ${event.data.amount.formatted} ${token.symbol}`);| Header | Purpose |
|---|---|
X-Webhook-Signature | Stripe-format HMAC-SHA256 signature: t=<unix-seconds>,v1=<hex> |
X-Webhook-Event | Event name in dot notation: transaction.detected or transaction.confirmed |
X-Webhook-Event-Id | UUIDv5, stable across retries — use as dedup key |
X-Webhook-Delivery-Id | UUID, changes on every attempt — for support correlation only |