Webhooks are how the Platform API pushes blockchain activity to your backend. When a monitored address sees a transaction, Vilna sends a signed HTTP POST to your webhook notification channel - no polling.
This page is the delivery contract: the event types, the payload, the headers, how to verify a delivery is authentic, and how retries and testing work. To create or manage a channel, see Notification channels; for the event model itself, see Events & monitoring.
Signed HTTP POST HMAC-SHA256 Dedup by event_id Ack within 10s
Every webhook channel states a payload_version - v1 or v2 - when you create it. The version freezes into each notification the first time it is sent; changing it later, through a channel PATCH, only applies to notifications sent from that point on. After you switch a channel to v2, retries of notifications already frozen on v1 keep going out in the v1 shape, alongside new v2 sends. A channel created before the v2 launch stays on v1 until you switch it.
A channel's payload_version decides which event name it receives.
A payload_version: v2 channel receives one event for every subscribed transaction:
| Event | When it fires |
|---|---|
transaction.notification | A subscription's trigger condition was met. |
A test delivery reuses the same event name - check is_test in the body to tell it apart from a real one.
A payload_version: v1 channel receives one of two events. It supports only block triggers with after_blocks: 0 and network triggers; mempool requires v2.
| Event | When it fires |
|---|---|
transaction.detected | Included in a block: a block trigger with after_blocks: 0 was met. An early signal, not finality. |
transaction.confirmed | Reached the chain's confirmation policy. This does not guarantee irreversibility; reconcile current transaction state before acting. |
A test delivery reuses these event names too - check is_test_message in the body.
The event name arrives in the X-Webhook-Event header.
Each delivery is an HTTP POST with a JSON body and signed headers.
| Header | What it carries |
|---|---|
X-Webhook-Signature | The HMAC-SHA256 signature, t=<unix>,v1=<hex> - see verifying |
X-Webhook-Event | v2: always transaction.notification - a test carries the same name, check is_test in the body. v1: transaction.detected or transaction.confirmed - a test reuses these, check is_test_message in the body |
X-Webhook-Version | The channel's payload_version, v1 or v2. Equal to version in a v2 body; the v1 body carries no such field |
X-Webhook-Event-Id | UUIDv5, stable across retries - your dedup key |
X-Webhook-Delivery-Id | UUID, changes on every attempt - for support correlation |
Every delivery is a JSON body, in the shape your channel's payload_version picked:
{
"version": "v2",
"event_id": "550e8400-e29b-41d4-a716-446655440020",
"subscription_id": "550e8400-e29b-41d4-a716-446655440002",
"is_test": false,
"item": {
"chain_gid": "eip155:1",
"txid": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"created_at": "2024-01-15T10:30:00Z",
"activity": [
{
"chain_gid": "eip155:1",
"txid": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"address": "0x742d35cc6634c0532925a3b8d1e1d78d8E15e5c4",
"asset_gid": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7",
"direction": "out",
"delta": { "base": "-1000000", "formatted": "-1.0" },
"created_at": "2024-01-15T10:30:00Z",
"entry_kind": "token_transfer"
}
],
"details": {
"family": "evm",
"events": [
{
"event_index": 0,
"kind": "transfer",
"asset_gid": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7",
"amount": { "base": "1000000", "formatted": "1.0" },
"operation": "transfer",
"from_address": "0x742d35cc6634c0532925a3b8d1e1d78d8E15e5c4",
"to_address": "0x95212290dd7278aa3ddd389cc1e1d165cc4bafe5"
}
],
"is_success": true,
"fee": { "base": "21000000000000000", "formatted": "0.021" },
"block": {
"block_hash": "0x9f1c2a3b4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8",
"block_height": 18500000
},
"transaction_index": 42,
"from_address": "0x742d35cc6634c0532925a3b8d1e1d78d8E15e5c4",
"to_address": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"gas_used": 65000
}
},
"references": {
"tokens": {
"eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7": {
"gid": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7",
"symbol": "USDT",
"name": "Tether USD",
"decimals": 6
}
},
"blockchains": {
"eip155:1": {
"gid": "eip155:1",
"name": "ethereum",
"short_name": "eth",
"image_url": "https://raw.githubusercontent.com/vilna-io/icons/refs/heads/main/chains/ethereum.svg"
}
},
"addresses": {
"0x742d35cc6634c0532925a3b8d1e1d78d8E15e5c4": "Hot wallet"
}
}
}version- the frozen webhook body version,v2. Independent of the HTTP API version.event_id- identity of this notification. Your dedup key on retries and manual resends.subscription_id- the subscription that selected this transaction. Null only for a channel test.is_test-truefor a channel test,falsefor a real transaction.item- the transaction itself, with its parsed events and per-address activity.references- lookup maps for the tokens, chains and addresses the transaction touches, so you rarely need another call to resolve names and symbols.
{
"event_id": "f47ac10b-58cc-5372-a567-0e02b2c3d479",
"item": { "...": "the Transaction object, with its events and per-address activity" },
"references": {
"tokens": { "...": "token GID -> details" },
"blockchains": { "...": "chain GID -> details" },
"addresses": { "...": "address -> label" }
},
"is_test_message": false
}event_id- UUIDv5, matches theX-Webhook-Event-Idheader. It is part of the signed body, so it is tamper-evident. Your dedup key.item- the full transaction, including its parsed events (transfers, fees, approvals) and per-address deltas.references- lookup maps for the tokens, chains and addresses in the transaction, so you don't need extra calls to resolve names and symbols.is_test_message-truefor test deliveries,falsefor real blockchain events.
A signature proves a delivery came from Vilna and was not tampered with. Verify every delivery before acting on it. The X-Webhook-Signature header has two parts:
Timestamp - Unix seconds when Vilna signed the delivery. It drives the replay window.
t=1714262800Signature - the hex HMAC-SHA256 over the signed payload. During a secret rotation two v1= entries appear, so a receiver holding either secret can validate.
v1=5257a8696bc5…The signed payload is the t value, a literal dot, then the raw request-body bytes: {t}.{rawBody}. Verify it in five steps - the same in any language:
Parse the header into its t value and one or more v1 values. Reject a missing or malformed timestamp. Each v1 signature must contain exactly 64 hexadecimal characters; skip malformed candidates.
Check the timestamp. Reject the delivery if t is more than 5 minutes from now - this blocks replays. Don't set the tolerance to zero; clock skew between servers will drop legitimate deliveries.
Rebuild the signed payload - the t value, a dot, then the raw body bytes. Never re-serialize parsed JSON; it will not byte-match.
Compute HMAC-SHA256 of that payload with your webhook_secret. Decode the v1 candidates from hex into 32-byte values for comparison.
Compare your result against each v1 with a constant-time comparison. Accept if any one matches - that covers the rotation window.
Compare in constant time. Use a cryptographic byte-comparison function. A malformed signature must fail verification rather than raise a handler error.
Sign the raw bytes. Hash the body exactly as received, before any JSON parsing - a re-serialized object will not byte-match.
Accept any known v1=. Ignore unknown signature schemes and accept only when a supported signature verifies.
The signing secret is unique to the channel. Store it immediately after channel creation or secret rotation.
Repeat the original request to create a channel or rotate its secret with the same Idempotency-Key to recover the response for 60 seconds from successful completion, under current authorization and only while the secret remains current. Retries do not extend the deadline. Later GETs return masks and timestamps, never plaintext. After expiry or unavailable replay, inspect channel and secret metadata before deliberately rotating; a fresh key can create another channel or rotate again.
Rotation keeps deliveries verifiable with zero downtime:
- Rotate to issue a new secret. For 24 hours both the old and new secret sign each delivery (two
v1=entries), so you can redeploy receivers gradually. - End the overlap early once every receiver has the new secret - after that, deliveries are signed with the current secret only.
- A read endpoint returns the current secret's mask and timestamps (and the previous secret's metadata during the overlap) - never the plaintext.
Rotate whenever someone with access to the secret leaves the team.
A valid HMAC signature proves that a holder of the shared secret signed the payload. Before an irreversible action, verify the transaction or invoice state through the Platform API or the chain.
- Acknowledge fast. Return an HTTP
2xxwithin 10 seconds; do the real work afterwards. - Failures are retried. A non-
2xxresponse or a timeout is retried with backoff. Retries reuse the sameevent_id, so your dedup logic makes them safe. - Deduplicate by
event_id. It is stable across retries and present in both the header and the signed body - skip anything you have already processed. - One delivery per subscription and transaction. A subscription notifies a given transaction at most once; re-inclusion or a later content revision never repeats or corrects a notice already sent. The one exception is a
mempoolsubscription, which can notify a second time if the transaction leaves the mempool for over an hour and reappears. - No notice for a reorg. If a transaction you were told about later drops out of the chain, there is no follow-up delivery - reconcile through the API if you need certainty. See triggers for the full model.
max_attemptscaps the automatic attempts, counting the first. They run on a fixed schedule set by the service, about a day across ten attempts (1-10). A channel disables itself once they're exhausted or on a confirmed bad destination; see when a channel switches itself off.- Inspect the notification history. Every attempt records the HTTP status your endpoint returned.
Send a sample delivery to a channel at any time with the test action - it produces a real signed request:
Open Test a channel, select family: evm and supply a separate Idempotency-Key. The operation page provides request examples and response details.
It answers 202 with a notification_id.
Use it to validate your signature checking and endpoint wiring before relying on the channel in production. Test deliveries appear in the notification history with is_test: true.
Keep the original Idempotency-Key unchanged with the same body if the HTTP response is lost. The accepted response replays for 24 hours after completion, subject to current access; it does not send another test. Inspect notification history before intentionally starting another test with a new key. The key remains bound after replay expiry. HTTP mutation replay, signature timestamp checks and deduplicating webhook event_id solve separate problems.