关于 nansen-alerts-webhook-listener
nansen-alerts-webhook-listener 是用于搭建本地接收器的技能,可接收 Nansen 智能提醒 Webhook。它会验证签名,也可将已验证提醒转发到 OpenClaw;提醒仍需手动创建或修改。
主要功能
- 01创建本地 Node.js HTTP 监听器
- 02验证 HMAC-SHA256 签名
- 03通过 ngrok 或 localtunnel 暴露服务
- 04可将已验证负载转发到 OpenClaw
- 05限制请求方法和正文大小
适用场景
- 01在本地接收 Nansen 智能提醒 Webhook
- 02将已验证提醒转发到 OpenClaw Gateway
- 03配置提醒时查看 Webhook 负载
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ nansen-ai/ nansen-cli --skill nansen-alerts-webhook-listener安装后在 Claude Code 中输入/nansen-alerts-webhook-listener确认已加载。
详细介绍
身份验证
Nansen 账户 API 调用接受已选定的 nansen:api 浏览器会话,或具有相同权限的常规 API 密钥。NANSEN_API_KEY 优先级最高;可选的 primaryEnv 可保留已配置密钥的注入。运行 nansen auth status 可离线进行选择。仅当缓存的访问凭证过期时,才允许在已授权的任务期间自动续期。若选择匿名状态、状态无效、续期受阻或存在不确定性,或实际身份验证失败,请停止;绝不要丢弃凭证,也不要回退到匿名 x402 付款。浏览器登录不会授予钱包签名权限、特权服务身份或持久的 MCP 集成密钥。请保留下文中的所有确认、签名、制裁及地理位置检查。浏览器登录已在 CLI 2.0.0 中提供,但仅限于 macOS arm64 预览范围。
告警 Webhook 监听器
设置本地 HTTP 服务器,以实时接收 Nansen 智能告警 Webhook 负载。
工作原理
Nansen 智能告警支持 webhook 渠道类型。告警触发时,Nansen 会向你的 webhook URL 发送带有 JSON 负载的 HTTP POST 请求。此技能会设置:
- 一个本地 HTTP 服务器(Node.js,无外部依赖),用于接收并显示告警负载
- HMAC-SHA256 签名验证,确保只接受真实的 Nansen 负载
- 一个公共隧道,让 Nansen 的服务器能够访问你的本地计算机
此技能不会创建或修改告警。 它会设置监听器基础设施,然后概述用户需要执行的操作,以开始接收告警。
OpenClaw 用户: 如果 OpenClaw 正在同一台计算机上本地运行,webhook 服务器可以将已验证的告警负载转发到 OpenClaw 的 Gateway(/hooks/agent),为每条告警触发一次 Agent 轮次。设置 OPENCLAW_GATEWAY_URL 环境变量即可启用此功能。请参阅下方的 OpenClaw 集成部分。
安全警告
继续操作前,请告知用户:
此技能会在你的计算机上启动 HTTP 服务器,并通过隧道(ngrok 或 localtunnel)将其暴露到互联网。虽然服务器只绑定到 localhost(
127.0.0.1)——也就是说,你本地网络中的其他人无法直接访问它——但隧道会创建一个公共 URL,互联网上的任何人都可以向其发送请求。已采取的缓解措施:
- HMAC-SHA256 签名验证会拒绝所有未经 Nansen 签名的请求
- 1 MB 的请求体大小上限可防止内存滥用
- 只接受
POST /webhook和GET /health;其他所有请求均返回 404请注意:
- 隧道 URL 可被公开发现(ngrok URL 可以被枚举)
- 未签名的请求仍会到达你的计算机——虽然会被拒绝,但连接仍会建立
- 使用完毕后请停止隧道,以关闭公共端点
继续操作前,请等待用户确认。
执行计划
请按顺序执行以下步骤。不要跳过签名验证——这是强制要求。
步骤 0:选择隧道提供商
开始之前,询问用户想使用哪家隧道提供商:
| ngrok(推荐) | localtunnel | |
|---|---|---|
| 稳定性 | 稳定 — 通过 keepalive 保持持久连接 | 不稳定 — 免费中继会在闲置时无提示地断开连接,隧道可能随机失效 |
| 安装 | brew install ngrok + 在 ngrok.com 注册免费账户 | 无需安装(npx localtunnel) |
| HTTPS | 支持 | 支持 |
| 是否需要身份验证 | 需要(从 ngrok.com 获取免费 authtoken) | 不需要 |
推荐使用 ngrok。 localtunnel 虽然方便,但不可靠 — 测试中,隧道会在几分钟后悄无声息地退出,导致告警因“503 Tunnel Unavailable”而失败。ngrok 能保持稳定连接。
检查 ngrok 是否可用:
which ngrok && ngrok version
如果尚未安装,请告知用户:
brew install ngrok(或从 ngrok.com 下载)- 在 ngrok.com 创建免费账户并复制 authtoken
ngrok config add-authtoken <token>
如果用户更倾向于使用 localtunnel,或无法安装 ngrok,则使用 localtunnel,但要提醒用户隧道可能会断开,他们需要重启隧道并更新告警的 webhook URL。
步骤 1:生成 webhook 密钥
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
保存输出 — 服务端和告警配置都需要用到它。此后绝不要记录或回显该密钥。
步骤 2:编写 webhook 接收脚本
在当前工作目录中创建 nansen-webhook-server.mjs。只能使用 Node.js 内置模块(node:http、node:crypto)。无需运行 npm install。
要求 — 请严格遵守:
| 要求 | 详情 |
|---|---|
| 绑定地址 | 仅限 127.0.0.1 — 绝不要使用 0.0.0.0 |
| 默认端口 | 9477(通过 PORT 环境变量覆盖) |
| Webhook 路径 | POST /webhook — 对所有其他方法/路径组合返回 404 |
| 健康检查 | GET /health → 200 {"status":"ok"} |
| 签名验证 | 使用 HMAC-SHA256 和时序安全比较验证 x-nansen-signature 标头。不匹配时返回 401。 |
| 密钥验证 | 如果缺少 WEBHOOK_SECRET 环境变量或其长度不足 16 个字符,则在启动时退出 |
| 负载记录 | 将有效的 JSON 负载以 ISO 时间戳漂亮格式打印到 stdout |
| 请求大小限制 | 拒绝大于 1 MB 的请求正文(413),以防止内存滥用 |
| 优雅关闭 | 处理 SIGINT 和 SIGTERM — 关闭服务端,然后退出 |
| 转发到 OpenClaw | 如果设置了 OPENCLAW_GATEWAY_URL 环境变量,则通过 POST 将已验证的负载转发到 <url>/hooks/agent。如果设置了 OPENCLAW_AUTH_TOKEN,则将其作为 Bearer token 发送。记录转发成功或失败。 |
| 无依赖项 | 仅使用 node:http、node:https 和 node:crypto — 不使用任何 npm 包 |
签名验证 — 使用时序安全比较:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
// Nansen sends "sha256=<hex>" — strip the prefix before comparing
const sig = signatureHeader.startsWith('sha256=') ? signatureHeader.slice(7) : signatureHeader;
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
try {
return timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'));
} catch {
return false; // length mismatch
}
}
完整服务端模板:
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const PORT = parseInt(process.env.PORT || '9477', 10);
const SECRET = process.env.WEBHOOK_SECRET;
const MAX_BODY = 1_048_576; // 1 MB
// Optional: forward verified payloads to a local OpenClaw Gateway
const OPENCLAW_URL = process.env.OPENCLAW_GATEWAY_URL; // e.g. http://localhost:3000
const OPENCLAW_TOKEN = process.env.OPENCLAW_AUTH_TOKEN;
if (!SECRET || SECRET.length < 16) {
console.error('WEBHOOK_SECRET env var required (minimum 16 characters).');
console.error('Generate one: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
process.exit(1);
}
function verifySignature(rawBody, signatureHeader) {
if (!signatureHeader) return false;
// Nansen sends "sha256=<hex>" — strip the prefix before comparing
const sig = signatureHeader.startsWith('sha256=') ? signatureHeader.slice(7) : signatureHeader;
const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex');
try {
return timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'));
} catch {
return false;
}
}
async function forwardToOpenClaw(payload) {
if (!OPENCLAW_URL) return;
const url = `${OPENCLAW_URL.replace(/\/+$/, '')}/hooks/agent`;
const headers = { 'Content-Type': 'application/json' };
if (OPENCLAW_TOKEN) headers['Authorization'] = `Bearer ${OPENCLAW_TOKEN}`;
try {
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
if (res.ok) {
console.log(`[${ts()}] Forwarded to OpenClaw (${res.status})`);
} else {
console.error(`[${ts()}] OpenClaw forward failed (${res.status})`);
}
} catch (err) {
console.error(`[${ts()}] OpenClaw forward error: ${err.message}`);
}
}
function ts() { return new Date().toISOString(); }
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end('{"status":"ok"}');
}
if (req.method !== 'POST' || req.url !== '/webhook') {
res.writeHead(404);
return res.end();
}
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY) {
res.writeHead(413);
res.end('{"error":"Payload too large"}');
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (res.writableEnded) return;
const rawBody = Buffer.concat(chunks).toString('utf8');
const signature = req.headers['x-nansen-signature'];
if (!verifySignature(rawBody, signature)) {
console.error(`[${ts()}] REJECTED — invalid signature`);
res.writeHead(401, { 'Content-Type': 'application/json' });
return res.end('{"error":"Invalid signature"}');
}
let payload;
try {
payload = JSON.parse(rawBody);
console.log(`\n[${ts()}] Alert received:`);
console.log(JSON.stringify(payload, null, 2));
} catch {
console.error(`[${ts()}] WARNING — valid signature but malformed JSON`);
}
// Forward to OpenClaw if configured (fire-and-forget — don't block response)
if (payload) forwardToOpenClaw(payload);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"received":true}');
});
});
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
console.log(`\n${sig} — shutting down`);
server.close(() => process.exit(0));
});
}
server.listen(PORT, '127.0.0.1', () => {
console.log(`Webhook listener ready — http://127.0.0.1:${PORT}/webhook`);
if (OPENCLAW_URL) console.log(`OpenClaw forwarding → ${OPENCLAW_URL}/hooks/agent`);
console.log('Waiting for alerts… (Ctrl+C to stop)\n');
});
步骤 3:启动服务端和隧道
启动服务端:
WEBHOOK_SECRET='<secret>' node nansen-webhook-server.mjs
然后启动公网隧道,以便 Nansen 的服务器能够连接到它。
ngrok(推荐):
ngrok http 9477
从 ngrok 的输出或其本地 API 获取公网 URL:
curl -s http://127.0.0.1:4040/api/tunnels | node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).tunnels[0]?.public_url))"
Webhook URL 为 https://<subdomain>.ngrok-free.dev/webhook。
localtunnel(备用 — 不可靠):
npx localtunnel --port 9477
这会打印类似 https://xxx.loca.lt 的 URL。Webhook URL 为 https://xxx.loca.lt/webhook。
警告: localtunnel 的免费中继会在几分钟后悄无声息地断开连接。发生这种情况时,所有告警都会因“503 Tunnel Unavailable”而失败,直到你重启隧道并更新告警的 webhook URL。除非有特殊原因,否则请使用 ngrok。
注意: 隧道 URL 是临时的 — 每次重启都会变化。如需永久部署,请将服务端部署到具有静态 URL 的主机上。
第 4 步:提供后续步骤摘要
请勿创建或修改任何提醒。 请改为输出清晰的摘要,向用户说明已设置的内容以及他们接下来需要做什么。
摘要必须包括:
- 确认已创建的内容(服务脚本路径和生成的密钥)
- 启动服务和隧道的命令(填入实际密钥)
- 用户应运行的确切
nansen alerts create或nansen alerts update命令,且--webhook和--webhook-secret标志须填入隧道 URL 和密钥;但提醒专属标志(--name、--type、--chains等)须保留为占位符,供用户填写 - 提醒用户必须先运行服务和隧道,再创建提醒(Nansen 会在创建时验证 webhook 端点)
- 说明隧道 URL 是临时的,重启后会改变
摘要格式示例:
## Webhook listener ready
**Server script:** ./nansen-webhook-server.mjs
**Port:** 9477
### To start receiving alerts:
1. Start the server (keep this terminal open):
WEBHOOK_SECRET='<actual-secret>' node nansen-webhook-server.mjs
2. In a new terminal, start the tunnel:
ngrok http 9477 # recommended
# or: npx localtunnel --port 9477 (unreliable — tunnel drops silently)
3. Create an alert pointing to your webhook (fill in your alert details):
nansen alerts create \
--name '<your alert name>' \
--type <sm-token-flows|common-token-transfer|smart-contract-call> \
--chains <chains> \
--webhook 'https://<your-tunnel-url>/webhook' \
--webhook-secret '<actual-secret>' \
[type-specific flags...]
Or add the webhook to an existing alert:
nansen alerts update <alert-id> \
--webhook 'https://<your-tunnel-url>/webhook' \
--webhook-secret '<actual-secret>'
Note: The tunnel URL changes each time you restart. Update the alert
webhook URL if you restart the tunnel.
See `nansen alerts create --help` for full flag reference per alert type.
安全检查清单
- 始终使用 webhook 密钥——没有密钥时,服务会拒绝启动
- 始终验证签名——绝不接受未经验证的负载
- 仅绑定到 localhost——隧道负责公开访问;直接绑定到
0.0.0.0会使你暴露于未经身份验证的流量 - 使用 HTTPS——localtunnel 和 ngrok 默认都通过 HTTPS 建立隧道
- 正文大小限制——1 MB 上限可防止超大请求耗尽内存
- 时序安全比较——防止针对签名的时序侧信道攻击
故障排除
| 症状 | 解决方法 |
|---|---|
| 每次请求都出现“Invalid signature” | 确保 WEBHOOK_SECRET 和 --webhook-secret 中使用的是完全相同的密钥 |
| 创建提醒时出现“Failed to send welcome message” | 先启动服务和隧道,再创建提醒 |
| 未收到提醒 | 检查 nansen alerts list --table——提醒是否已启用?Webhook URL 是否正确(包含 /webhook)? |
| 隧道 URL 已过期/隧道已断开 | 重启隧道,获取新 URL,然后运行 nansen alerts update <id> --webhook '<new-url>/webhook'。如果这种情况持续发生,请从 localtunnel 切换到 ngrok。 |
| 端口已被占用 | 设置其他端口:PORT=9478 WEBHOOK_SECRET='...' node nansen-webhook-server.mjs,并相应更新隧道 |
OpenClaw 集成
如果用户在同一台机器上本地运行 OpenClaw,webhook 服务可以将已验证的提醒负载转发给 OpenClaw 的 Gateway,为每条提醒触发一次 Agent 调用。
流程: Nansen → ngrok → webhook server (signature check) → OpenClaw /hooks/agent
其他环境变量
| 变量 | 必需 | 用途 |
|---|---|---|
OPENCLAW_GATEWAY_URL | 是 | OpenClaw Gateway 基础 URL(例如 http://localhost:3000) |
OPENCLAW_AUTH_TOKEN | 启用身份验证时必需 | OpenClaw webhook 端点的 Bearer 令牌 |
启动命令(启用 OpenClaw 转发)
WEBHOOK_SECRET='<secret>' \
OPENCLAW_GATEWAY_URL='http://localhost:3000' \
OPENCLAW_AUTH_TOKEN='<token>' \
node nansen-webhook-server.mjs
服务会同时记录提醒负载和 OpenClaw 转发状态。如果无法连接 OpenClaw,转发会静默失败(提醒仍会记录到 stdout)。
询问用户
启用 OpenClaw 转发前,请询问:
- 是否在本地运行 OpenClaw?使用哪个端口?
- 他们的 Gateway 是否需要身份验证?如果需要,Bearer 令牌是什么?
如果他们不知道,或者没有运行 OpenClaw,请跳过此步骤——服务单独运行也完全正常。
备注
- 服务不依赖任何 npm 包——仅使用 Node.js 内置模块
- 一个服务可以接收来自多个 Nansen 提醒的消息(前提是它们使用相同的 webhook 密钥)
- 用于生产环境时,请部署到具有静态 URL 的云主机,并通过带 TLS 的反向代理运行
x-nansen-signature标头的格式为sha256=<HMAC-SHA256(secret, rawBody)>——比较前请移除sha256=前缀