Webhooks
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.
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, then Base64-encoding the result.
Python (Flask)
python
import hmac, hashlib, base64
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 = base64.b64encode(
hmac.new(SIGNING_SECRET.encode("utf-8"), raw_body, hashlib.sha256).digest()
).decode()
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)
javascript
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("base64");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
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
<?php
define('SIGNING_SECRET', 'your_signing_secret');
$raw_body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = base64_encode(hash_hmac('sha256', $raw_body, SIGNING_SECRET, true));
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've already handled it:
javascript
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 will retry the delivery. Best practice: respond first, then process.
javascript
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 successfully |
| OUTGOING_FAILED | The transaction failed before or after broadcast |
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 |
Example Payloads
TRANSACTION_REQUEST
json
{
"walletId": "685121237d7d1e0007ac1a1d",
"webhookId": "68516e9c6620b0a4790ed541",
"idempotencyKey": "928fe178...d371dda9",
"apiVersion": "2025-09-23",
"data": {
"event": "TRANSACTION_REQUEST",
"item": {
"vaultAccountId": "69ba58c529eb49000746b311",
"assetId": "6913471c6b2794841dc8fb6f",
"unit": "ETH",
"requestId": "686cd2860ca2f4fd25f77099",
"transactionType": "COIN"
}
}
}
TRANSACTION_APPROVED
json
{
"data": {
"event": "TRANSACTION_APPROVED",
"item": {
"requestId": "686cd2860ca2f4fd25f77099",
"unit": "ETH",
"requiredApprovals": 2,
"currentApprovals": 2,
"requiredRejections": 1,
"currentRejections": 0,
"transactionType": "COIN"
}
}
}
TRANSACTION_REJECTED
json
{``
"data": {
"event": "TRANSACTION_REJECTED",
"item": {
"requestId": "686cd2860ca2f4fd25f77099",
"requiredApprovals": 2,
"currentApprovals": 0,
"requiredRejections": 1,
"currentRejections": 1
}
}
}
OUTGOING_MINED
json
{
"data": {
"event": "OUTGOING_MINED",
"item": {
"requestId": "686cd2860ca2f4fd25f77099",
"transactionId": "0x37c2e069...efb50623",
"unit": "ETH",
"currentApprovals": 2,
"currentRejections": 0
}
}
}
OUTGOING_FAILED
json
{
"data": {
"event": "OUTGOING_FAILED",
"item": {
"requestId": "686cd2860ca2f4fd25f77099",
"failedReason": "INSUFFICIENT_FUNDS",
"currentApprovals": 1,
"currentRejections": 0
}
}
}
INCOMING_CONFIRMED_COIN_TX
json
{
"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
json
{
"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"
}
}
}
}
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