Webhooks
Webhooks (also called callbacks) are HTTP POST requests that Vaultody sends to your server when a subscribed event occurs. They allow your application to react to blockchain activity and transaction status changes in real time, without polling the API.
Setting Up Webhooks
- In the Dashboard, go to Developers → Webhooks
- Click Add Endpoint and enter your publicly accessible callback URL
- Set a Signing Secret — your own secret string used to verify incoming requests
- Select the events you want to subscribe to
- Save the webhook
Your callback URL must be publicly accessible over HTTPS. Local development URLs (e.g.
localhost) will not receive webhooks. Use a tool like ngrok to expose a local server during development.
A subscription starts in a pending state and begins delivering once it is active. Only active subscriptions receive callbacks, so if a newly created webhook is silent, check its state first.
Verifying the Signature
Every webhook request includes an X-Signature header. Always verify this signature before processing
the event — it proves the request genuinely came from Vaultody and has not been tampered with.
Vaultody computes the signature by running HMAC-SHA256 over the raw request body using your Signing Secret, and sends the result as a lowercase hexadecimal string. Verify against the exact bytes you received: parsing the JSON and re-serialising it can change the body and break the comparison.
Python (Flask)
import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = "your_signing_secret"
@app.route("/webhook", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Signature", "")
raw_body = request.get_data() # raw bytes — do not parse first
expected = hmac.new(
SIGNING_SECRET.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
payload = request.get_json()
process_event(payload["data"]["event"], payload["data"]["item"])
return "", 200
Node.js (Express)
const express = require("express");
const crypto = require("crypto");
const app = express();
const SIGNING_SECRET = "your_signing_secret";
// Use express.raw() to preserve the raw body for signature verification
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-signature"] || "";
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(req.body)
.digest("hex");
// Compare in constant time, but check the lengths first —
// timingSafeEqual throws on buffers of different sizes.
const sigBuf = Buffer.from(signature, "utf8");
const expBuf = Buffer.from(expected, "utf8");
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(expBuf, sigBuf)) {
return res.status(401).send("Invalid signature");
}
// Respond 200 immediately, then process asynchronously
res.status(200).end();
const payload = JSON.parse(req.body.toString());
processEvent(payload.data.event, payload.data.item);
});
PHP
<?php
define('SIGNING_SECRET', 'your_signing_secret');
$raw_body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw_body, SIGNING_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$payload = json_decode($raw_body, true);
process_event($payload['data']['event'], $payload['data']['item']);
http_response_code(200);
Important: Always use a timing-safe comparison (
hmac.compare_digest,crypto.timingSafeEqual,hash_equals) to prevent timing attacks. Never use==or===to compare signatures.
Idempotency — Preventing Double Processing
Vaultody may deliver the same webhook more than once if your server did not respond with HTTP 200 in
time. Every webhook includes an idempotencyKey — store it and skip processing if you have already
handled it:
const { idempotencyKey, data } = payload;
if (await db.processedWebhooks.exists({ idempotencyKey })) {
return; // already handled
}
await db.processedWebhooks.insert({ idempotencyKey, processedAt: new Date() });
// → now process the event
Responding to Webhooks
Your endpoint must return HTTP 200 within the timeout window. If Vaultody does not receive a 200, it retries the delivery.
Best practice: respond first, then process.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req)) return res.status(401).end();
res.status(200).end(); // ← acknowledge immediately
setImmediate(() => { // ← process asynchronously
const payload = JSON.parse(req.body.toString());
handleEvent(payload);
});
});
Event Reference
Outgoing Transaction Events
| Event | When it fires |
|---|---|
TRANSACTION_REQUEST |
A new outgoing transaction request has been created and is awaiting approval |
TRANSACTION_APPROVED |
The required number of approvals has been reached |
TRANSACTION_REJECTED |
An approver rejected the transaction |
TRANSACTION_BROADCASTED |
The transaction has been submitted to the blockchain mempool |
OUTGOING_MINED |
The transaction has been confirmed on-chain and executed successfully |
OUTGOING_MINED_WITH_ERRORS |
The transaction was mined but execution failed on-chain (for example an EVM revert). The network fee is spent and no funds moved. failedReason explains the failure. This is a final state — handle it alongside OUTGOING_MINED and OUTGOING_FAILED |
OUTGOING_FAILED |
The transaction failed before broadcast, or was rejected by the network |
Incoming Transaction Events
| Event | When it fires |
|---|---|
INCOMING_MINED_TX |
An incoming transaction first appears in a block (not yet final) |
INCOMING_CONFIRMED_COIN_TX |
An incoming native coin deposit (ETH, BTC, TRX, etc.) reached required confirmations |
INCOMING_CONFIRMED_TOKEN_TX |
An incoming token deposit (USDT, USDC, etc.) reached required confirmations |
INCOMING_CONFIRMED_INTERNAL_TX |
An internal EVM transaction (smart contract triggered) reached required confirmations |
Incoming deposits produce one confirmed-transaction event per confirmation until the per-chain target
is reached, so expect repeat deliveries for the same transactionId with an increasing
currentConfirmations. Treat the deposit as final when currentConfirmations reaches
targetConfirmations, and deduplicate on idempotencyKey.
Example Payloads
walletId in callback payloads carries the ID of the Vault the event belongs to — the same value
you pass as vaultId in API requests.
TRANSACTION_REQUEST
{
"walletId": "685121237d7d1e0007ac1a1d",
"webhookId": "68516e9c6620b0a4790ed541",
"idempotencyKey": "928fe178...d371dda9",
"apiVersion": "2026-03-20",
"data": {
"event": "TRANSACTION_REQUEST",
"item": {
"vaultAccountId": "69ba58c529eb49000746b311",
"assetId": "6913471c6b2794841dc8fb6f",
"unit": "ETH",
"requestId": "686cd2860ca2f4fd25f77099",
"transactionType": "COIN"
}
}
}
TRANSACTION_APPROVED
{
"data": {
"event": "TRANSACTION_APPROVED",
"item": {
"requestId": "686cd2860ca2f4fd25f77099",
"unit": "ETH",
"requiredApprovals": 2,
"currentApprovals": 2,
"requiredRejections": 1,
"currentRejections": 0,
"transactionType": "COIN"
}
}
}
TRANSACTION_REJECTED
{
"data": {
"event": "TRANSACTION_REJECTED",
"item": {
"requestId": "686cd2860ca2f4fd25f77099",
"requiredApprovals": 2,
"currentApprovals": 0,
"requiredRejections": 1,
"currentRejections": 1
}
}
}
OUTGOING_MINED
{
"data": {
"event": "OUTGOING_MINED",
"item": {
"requestId": "686cd2860ca2f4fd25f77099",
"transactionId": "0x37c2e069...efb50623",
"unit": "ETH",
"currentApprovals": 2,
"currentRejections": 0
}
}
}
OUTGOING_MINED_WITH_ERRORS
{
"data": {
"event": "OUTGOING_MINED_WITH_ERRORS",
"item": {
"requestId": "686cd2860ca2f4fd25f77099",
"transactionId": "0x37c2e069...efb50623",
"unit": "ETH",
"failedReason": "Failure",
"currentApprovals": 2,
"currentRejections": 0
}
}
}
OUTGOING_FAILED
{
"data": {
"event": "OUTGOING_FAILED",
"item": {
"requestId": "686cd2860ca2f4fd25f77099",
"failedReason": "INSUFFICIENT_FUNDS",
"currentApprovals": 1,
"currentRejections": 0
}
}
}
INCOMING_CONFIRMED_COIN_TX
{
"data": {
"event": "INCOMING_CONFIRMED_COIN_TX",
"item": {
"vaultAccountId": "69ba58c529eb49000746b311",
"assetId": "6913471c6b2794841dc8fb6f",
"address": "tb1q3ft8aq4daesecw8yfz0gt4hgursm0l6nhj06m9",
"amount": "0.005",
"unit": "BTC",
"transactionId": "cf0d9a82045c13dba627788d9508b3dfd761e46b49deba48d004c82e2f2f83f2",
"currentConfirmations": 6,
"targetConfirmations": 6,
"status": "success",
"minedInBlock": {
"height": 4547001,
"hash": "0000000000000004a25a24749cbca4ac6b2d62b9932f0fefbb9cb8a7a9e5619e",
"timestamp": 1750837928
}
}
}
}
INCOMING_CONFIRMED_TOKEN_TX
{
"data": {
"event": "INCOMING_CONFIRMED_TOKEN_TX",
"item": {
"address": "TDGFc6pDe5q2gc9zi4p2JQHfJTXVTBw7yu",
"tokenType": "TRC-20",
"transactionId": "a6f8225d5a905fc236e5d85d88f77d127d48e80bb456042853c8ed6210182f4f",
"currentConfirmations": 12,
"targetConfirmations": 12,
"status": "success",
"token": {
"tokenName": "Tether USD",
"tokenSymbol": "USDT",
"decimals": 6,
"tokensAmount": "100.0",
"contract": "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj"
}
}
}
}
Deposits and Assets
An incoming deposit produces webhooks only when the receiving address has the matching asset added. A deposit of an asset that has not been added to the address is held, produces no event, and is processed retroactively once you add the asset. Add the asset before you publish an address to a user — see Getting Started.
Customer's Signing Secret
The Signing Secret is set by you when creating the webhook subscription. It is used to generate the
X-Signature header sent by Vaultody on every callback.
- The Signing Secret is per subscription, not per callback
- You can use the same secret for all subscriptions or different ones for each
- If your Signing Secret is compromised, rotate it immediately in the Dashboard