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
- Requests and answers are JSON. Times are UTC, in ISO 8601.
- Addresses come back in lowercase. Send them in lowercase or checksummed: a mixed-case address must pass its EIP-55 checksum, since a failed checksum usually means a typo.
- Lists come in pages, newest first. Pass a page's
next_cursorascursorto get the next one. It isnullon the last page.limitgoes up to 100. - A finding is
pendingwhen first seen at the head of the chain,confirmedonce 20 blocks deep, andretractedif a chain reorganization removes it. Every finding carries its transaction hash, block, rule ID and rule version. - Errors answer with an HTTP status and a body your code can branch on. A request that fails validation also lists what was wrong:
{
"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_...
- Create keys in Settings, from a signed-in browser. A key is shown once. VIGIL keeps only a hash of it, so a lost key cannot be recovered, only replaced.
- Revoking a key stops it at once. An account can have 10 keys.
- A key acts as its owner for watches, alerts, webhooks and deliveries. It cannot create, list or revoke keys, or create sign-in links. Those need a signed-in browser, so a leaked key cannot be turned into a session that outlives it.
- A free account can watch 3 addresses, from the dashboard and with keys together.
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.
| Who | Requests per minute |
|---|---|
| Each IP address, without a key | 300 |
| Each API key | 600 |
| Each IP address, for contract profiles and sign-in, which cost chain reads or create records | 20 |
- Every answer says where you stand:
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Resetin seconds. - Past the limit, the answer is
429with{"error": "rate_limited"}and aRetry-Afterheader in seconds. - Only a key that exists counts as a key. A request with a made-up key counts against its IP address.
Public routes
No key needed. They read only chain facts, never anything about who is watching.
| Method | Path | What it does |
|---|---|---|
| GET | /rules | Every rule, with its version, severity, default parameters and whether it is available yet. |
| GET | /contract/:address/profile | What the address is: token, proxy, multisig, pool, ownable, pausable, role based. Read from the chain, cached for a while. |
| GET | /contract/:address/history | Every 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/:id | One finding: the chain fact behind an alert, with its evidence. |
| GET | /feed | The ecosystem feed as JSON. Takes cursor and limit. |
| GET | /featured | The featured contracts the feed covers. |
| GET | /status | How 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.
| Method | Path | What it does |
|---|---|---|
| GET | /me | The account: address, plan, limits and usage. |
| GET | /watches | Your watches. |
| POST | /watches | Watch 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/:id | Change a watch. Takes the same fields as creating one, except target. |
| DELETE | /watches/:id | Stop watching. |
| GET | /alerts | Your alerts, newest first. Filter by severity, status, watch_id, rule_id, unread, from and to. Takes cursor and limit. |
| GET | /alerts/summary | How many alerts are unread. |
| GET | /alerts/:id | One alert. |
| POST | /alerts/:id/read | Mark one alert read, or unread with {"read": false}. |
| POST | /alerts/:id/acknowledge | Acknowledge one alert. |
| POST | /alerts/read-all | Mark every alert read. |
| GET | /webhooks | Your webhook endpoints, without their secrets. |
| POST | /webhooks | Add an endpoint. Send url, and optionally description. The answer holds the signing secret, once. |
| PATCH | /webhooks/:id | Change the description, or turn the endpoint off and on with enabled. |
| DELETE | /webhooks/:id | Remove an endpoint. |
| POST | /webhooks/:id/test | Send a signed ping now, and report what happened. |
| GET | /discord | Your Discord channel webhooks, shown without their tokens. |
| POST | /discord | Add a channel. Send url, a Discord webhook URL, and optionally description. |
| PATCH | /discord/:id | Change the description, or turn it off and on with enabled. |
| DELETE | /discord/:id | Remove a channel. |
| POST | /discord/:id/test | Post a test message now, and report what happened. |
| GET | /telegram | Your Telegram chats, and whether Telegram is set up on this server. |
| POST | /telegram/link | A 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/:id | Turn a chat off and on with enabled. |
| DELETE | /telegram/:id | Remove a chat. |
| POST | /telegram/:id/test | Send a test message now, and report what happened. |
| GET | Your address, whether it is confirmed, and how often it gets email. | |
| PUT | Set the address, with mode ("instant" or "digest") and digest_hour in UTC. A new address gets a confirmation email. | |
| PATCH | Change mode or digest_hour only. | |
| POST | /email/resend | Send the confirmation email again. |
| DELETE | Remove the address. | |
| GET | /deliveries | What was sent where, and why anything failed. Takes alert_id and limit. |
| POST | /findings/:id/report | Report 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/report | Your 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.
- People read notifications, email, Telegram and Discord, so those follow the watch's quiet hours, though critical alerts can still come through. A rule that can fire over and over, such as a large mint, reaches each of them once per 5 minutes for a watch. Webhooks get every alert, since a server does not get tired.
- Email goes to one address per account, and only once it is confirmed by pressing Confirm in the email VIGIL sends. Choose each alert as it happens, or one digest a day at an hour you pick. Every email has a one-click unsubscribe.
- Discord takes a channel's webhook URL, from the channel's settings, under Integrations. No bot is needed. The URL is stored encrypted, and nothing in an alert can ping anyone. If Discord says the webhook was deleted, VIGIL turns it off and says why.
- Telegram alerts come from VIGIL's own bot. In Settings, press Connect Telegram, open the link, and press Start in a private chat, or pick a group to add the bot to. The link works once, for 15 minutes. An account can connect 5 chats. If the bot is blocked or removed from a group, VIGIL turns that chat off and says why.
- A correction to an alert someone already received always reaches them, whatever their settings say now.
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
X-Vigil-Signature:sha256=and the HMAC, in hex. See verifying signatures.X-Vigil-Timestamp: when the request was signed, in Unix seconds.X-Vigil-Delivery: the delivery ID. It stays the same when a delivery is retried.User-Agent: Vigil-Webhook/1andContent-Type: application/json.
Events
alert.confirmed: the change is 20 blocks deep. Most alerts arrive as this.alert.created: sent at 0 confirmations, labelled pending, for critical rules on watches withnotify_pendingon. Analert.confirmedoralert.retractedfollows.alert.retracted: a chain reorganization removed the event. Undo whatever the alert made you do, if you can.ping: fromPOST /webhooks/:id/test. It haswebhook_idin place ofalert.
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
- Answer with any 2xx within 10 seconds. Anything else counts as a failure, including a redirect, which is never followed.
- A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then given up. After 10 failed attempts in a row, the endpoint is turned off and shows why in Settings.
- Delivery is at least once. A retry after a timeout can repeat a request you did receive, so skip a
X-Vigil-Deliveryyou have already handled. - Endpoints must use HTTPS and resolve to a public address. VIGIL checks the address again on every request, and never sends to a private or internal network.
Verifying signatures
Check every request before acting on it. Anyone can send a request to your URL; only VIGIL knows the secret.
- Take the body exactly as received, before parsing it. Parsing and re-encoding changes the bytes that were signed.
- Compute HMAC-SHA256 of
{X-Vigil-Timestamp}.{body}, with the whole secret, including itswhsec_prefix, as the key. Write it in lowercase hex and putsha256=in front. - Compare that with
X-Vigil-Signaturein 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.
- RSS: https://vigilhq.xyz/feed.rss
- JSON:
GET /feed, where each item is a finding pluscontract_name,confirmed_atandretracted_at.