VIGIL

API and webhook docs

Read the public record of changes on Robinhood Chain, manage your watches from your own code, and receive signed webhooks the moment something changes.

Overview

Every route below is relative to this base URL:

https://vigilhq.xyz/api/v1
{
  "error": "invalid_request",
  "message": "The request is not valid",
  "issues": [{ "path": "target", "message": "Invalid address" }]
}

Reading a contract's history needs no key:

curl "https://vigilhq.xyz/api/v1/contract/0x0bd7d308f8e1639fab988df18a8011f41eacad73/history?limit=20"

API keys

Public routes need nothing. Private routes act for one account, and take an API key in the Authorization header:

Authorization: Bearer vgl_...

Watching an address with every rule that suits it, delivered to your webhooks:

curl -X POST "https://vigilhq.xyz/api/v1/watches" \
  -H "Authorization: Bearer $VIGIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target": "0x0bd7d308f8e1639fab988df18a8011f41eacad73", "rules": ["*"], "channels": ["webhook"]}'

Rate limits

Limits count requests in fixed one-minute windows.

WhoRequests per minute
Each IP address, without a key300
Each API key600
Each IP address, for contract profiles and sign-in, which cost chain reads or create records20

Public routes

No key needed. They read only chain facts, never anything about who is watching.

MethodPathWhat it does
GET/rulesEvery rule, with its version, severity, default parameters and whether it is available yet.
GET/contract/:address/profileWhat the address is: token, proxy, multisig, pool, ownable, pausable, role based. Read from the chain, cached for a while.
GET/contract/:address/historyEvery change VIGIL recorded to the contract itself, newest first. Takes rule_id, cursor and limit. What a watched wallet did is not listed: it reaches that wallet's watchers only.
GET/findings/:idOne finding: the chain fact behind an alert, with its evidence.
GET/feedThe ecosystem feed as JSON. Takes cursor and limit.
GET/featuredThe featured contracts the feed covers.
GET/statusHow VIGIL is doing: blocks behind the chain, alert latency and delivery success per channel, RPC error rates, and any open incidents. The /status page shows the same.

Private routes

These need an API key, and only ever show the key owner's own data.

MethodPathWhat it does
GET/meThe account: address, plan, limits and usage.
GET/watchesYour watches.
POST/watchesWatch an address. Send target, and optionally label, rules (rule IDs, or ["*"] for every rule that suits it), rule_params, channels, min_severity, muted, quiet_hours and notify_pending.
PATCH/watches/:idChange a watch. Takes the same fields as creating one, except target.
DELETE/watches/:idStop watching.
GET/alertsYour alerts, newest first. Filter by severity, status, watch_id, rule_id, unread, from and to. Takes cursor and limit.
GET/alerts/summaryHow many alerts are unread.
GET/alerts/:idOne alert.
POST/alerts/:id/readMark one alert read, or unread with {"read": false}.
POST/alerts/:id/acknowledgeAcknowledge one alert.
POST/alerts/read-allMark every alert read.
GET/webhooksYour webhook endpoints, without their secrets.
POST/webhooksAdd an endpoint. Send url, and optionally description. The answer holds the signing secret, once.
PATCH/webhooks/:idChange the description, or turn the endpoint off and on with enabled.
DELETE/webhooks/:idRemove an endpoint.
POST/webhooks/:id/testSend a signed ping now, and report what happened.
GET/discordYour Discord channel webhooks, shown without their tokens.
POST/discordAdd a channel. Send url, a Discord webhook URL, and optionally description.
PATCH/discord/:idChange the description, or turn it off and on with enabled.
DELETE/discord/:idRemove a channel.
POST/discord/:id/testPost a test message now, and report what happened.
GET/telegramYour Telegram chats, and whether Telegram is set up on this server.
POST/telegram/linkA one-time link that opens VIGIL's bot: url for a private chat, group_url for a group. Pressing Start there connects the chat. It works for 15 minutes, and only the newest link works.
PATCH/telegram/:idTurn a chat off and on with enabled.
DELETE/telegram/:idRemove a chat.
POST/telegram/:id/testSend a test message now, and report what happened.
GET/emailYour address, whether it is confirmed, and how often it gets email.
PUT/emailSet the address, with mode ("instant" or "digest") and digest_hour in UTC. A new address gets a confirmation email.
PATCH/emailChange mode or digest_hour only.
POST/email/resendSend the confirmation email again.
DELETE/emailRemove the address.
GET/deliveriesWhat was sent where, and why anything failed. Takes alert_id and limit.
POST/findings/:id/reportReport a finding as wrong, once. Send reason ("did_not_happen", "wrong_details", "not_a_risk" or "other") and optionally note. It is reviewed, and a rule at fault is fixed with a new version.
GET/findings/:id/reportYour report on a finding, and what became of it.

Channels

Each watch chooses where its alerts go, besides the inbox: notifications on a browser or phone, email, Telegram, Discord, and webhooks. Set each one up in Settings first.

Webhooks

Add an endpoint in Settings or with POST /webhooks, then choose the webhook channel on a watch. VIGIL then sends each alert to every endpoint you have, as a POST with a JSON body.

Headers

Events

Body

An example, not a real event.

{
  "type": "alert.confirmed",
  "delivery_id": "dl_01kx3m7q2b9d4f6h8j0k2m4n6p",
  "sent_at": "2026-11-02T04:31:09.412Z",
  "alert": {
    "alert_id": "al_01kx3m7q1z8c3e5g7j9k1m3n5p",
    "finding_id": "fnd_01kx3m7q1y7b2d4f6h8j0k2m4n",
    "watch_id": "w_01kx2a9b8c7d6e5f4g3h2j1k0m",
    "label": "Treasury proxy",
    "chain_id": 4663,
    "rule_id": "PROXY_UPGRADE_V1",
    "rule_version": 1,
    "severity": "critical",
    "status": "confirmed",
    "title": "Implementation upgraded",
    "summary": "Implementation changed from 0xAA01…0001 to 0xBB02…0002",
    "contract": "0x7f3a00000000000000000000000000000000a991",
    "subject": "0x7f3a00000000000000000000000000000000a991",
    "block_timestamp": "2026-11-02T04:31:07.000Z",
    "block_hash": "0x5c1e…",
    "evidence": {
      "tx_hash": "0x9d2f…",
      "block_number": 51234567,
      "actor": "0x12ab000000000000000000000000000000009c04",
      "before": "0xaa01000000000000000000000000000000000001",
      "after": "0xbb02000000000000000000000000000000000002",
      "upgrades": 1
    },
    "dedupe_key": "4663:0x5c1e…:0x9d2f…:0x7f3a…a991:PROXY_UPGRADE_V1",
    "created_at": "2026-11-02T04:31:08.120Z",
    "read_at": null,
    "acknowledged_at": null
  }
}

Delivery

Verifying signatures

Check every request before acting on it. Anyone can send a request to your URL; only VIGIL knows the secret.

  1. Take the body exactly as received, before parsing it. Parsing and re-encoding changes the bytes that were signed.
  2. Compute HMAC-SHA256 of {X-Vigil-Timestamp}.{body}, with the whole secret, including its whsec_ prefix, as the key. Write it in lowercase hex and put sha256= in front.
  3. Compare that with X-Vigil-Signature in constant time, and refuse a timestamp more than 5 minutes from your clock.

In Node, with no dependencies:

import { createHmac, timingSafeEqual } from "node:crypto";

/**
 * rawBody: the request body exactly as received, before any JSON parsing.
 * headers: the request headers, with lowercase names as Node gives them.
 * secret: the whole signing secret, including its whsec_ prefix.
 */
export function verifyVigilSignature(rawBody, headers, secret) {
  const signature = headers["x-vigil-signature"];
  const timestamp = Number(headers["x-vigil-timestamp"]);
  if (typeof signature !== "string" || !Number.isInteger(timestamp)) return false;

  // Refuse anything more than 5 minutes off, so a captured request cannot be replayed later.
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = Buffer.from(
    "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"),
  );
  const received = Buffer.from(signature);
  return expected.length === received.length && timingSafeEqual(expected, received);
}

With Express:

import express from "express";

const app = express();

// express.raw keeps the exact bytes that were signed.
app.post("/vigil", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyVigilSignature(req.body.toString("utf8"), req.headers, process.env.VIGIL_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  // Deliveries arrive at least once. Skip a delivery_id you have already handled.
  res.status(200).end();
  handle(event);
});

SDK

@vigil/sdk wraps the API for TypeScript and JavaScript on a server: typed answers, every page of a list as one loop, errors that carry the API's code, waiting out rate limits, and checking webhooks. It needs Node 18.17 or later. Keep the key on the server: it acts as you.

import { createVigilClient, parseVigilWebhook } from "@vigil/sdk";

const vigil = createVigilClient({ baseUrl: "https://vigilhq.xyz/api/v1", apiKey: process.env.VIGIL_API_KEY });

await vigil.watches.create({ target: "0x…", rules: ["*"], channels: ["webhook"] });

for await (const alert of vigil.alerts.all({ severity: "critical" })) {
  console.log(alert.title, alert.evidence.tx_hash);
}

// In your webhook handler, with the body exactly as received:
const event = parseVigilWebhook(rawBody, request.headers, process.env.VIGIL_WEBHOOK_SECRET);

Integrations

Each of these is a complete, tested example in the VIGIL source, under examples/.

Trust Layer

VIGIL's public record feeds Trust Layer's RAP SHEET as VERIFIED items: changes of owner, upgrades, admin changes and pauses, each mapped to Trust Layer's catalog with its transaction and block. Only findings at or below the chain's safe head are used, as Trust Layer reads nothing newer. It needs no key.

// Final facts only: Trust Layer never reads past the safe head.
for await (const finding of vigil.contracts.allHistory(address)) {
  if (finding.status !== "confirmed" || finding.evidence.block_number > safeHead) continue;
  const fact = toVerifiedFact(finding); // ownership_transferred, contract_upgraded, …, or null
  if (fact) items.push(draft(fact.type, fact));
}

MANDATE

An agent policy engine watches the agent's counterparties for critical changes, freezes spending to one on the first alert, even before it is confirmed, and lifts the freeze if a reorganization retracts it.

await vigil.watches.create({ target: counterparty, rules: ["*"], channels: ["webhook"],
  min_severity: "critical", notify_pending: true });

// In the webhook handler:
if (event.type === "alert.created" || event.type === "alert.confirmed") await policy.freeze(event.alert.contract, event.alert);
if (event.type === "alert.retracted") await policy.unfreeze(event.alert.contract, event.alert.alert_id);

Launchpads

A launchpad watches every token it launches, and the wallet that launched it. A token's own creation is its setup, so nothing is reported until it actually changes. The wallet rules report the launcher sending away a large share of a supply, letting someone spend it, or launching another contract. Naming them, rather than asking for suggestions, makes them apply to a smart account too. Each launch takes two watches, so the plan needs enough, which the operator sets.

// Right after a launch is confirmed:
await vigil.watches.create({ target: token, rules: ["*"], channels: ["webhook"], label: `${symbol} token` });
await vigil.watches.create({
  target: deployer,
  rules: ["LARGE_TRANSFER_V1", "APPROVAL_LARGE_V1", "NEW_DEPLOYMENT_V1"],
  channels: ["webhook"],
  label: `${symbol} deployer`,
});

Feed and RSS

The ecosystem feed lists critical and high severity changes to featured contracts, once confirmed. A change a reorganization removes after that stays listed, marked retracted, and RSS readers get it as a new item. One retracted before it was confirmed never appears.