Jump to
Ctrl
+
/

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

  1. In the Dashboard, go to Developers → Webhooks
  2. Click Add Endpoint and enter your publicly accessible callback URL
  3. Set a Signing Secret — your own secret string used to verify incoming requests
  4. Select the events you want to subscribe to
  5. 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
Was this page helpful?
Yes
No
Powered by

On this page