nansen-alerts-webhook-listener

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Alert Webhook Listener

告警Webhook监听器

Set up a local HTTP server to receive Nansen smart alert webhook payloads in real-time.
搭建本地HTTP服务器,实时接收Nansen智能告警webhook payload。

How It Works

工作原理

Nansen smart alerts support a webhook channel type. When an alert fires, Nansen sends an HTTP POST with a JSON payload to your webhook URL. This skill sets up:
  1. A local HTTP server (Node.js, zero external dependencies) that receives and displays alert payloads
  2. HMAC-SHA256 signature verification so only authentic Nansen payloads are accepted
  3. A public tunnel so Nansen's servers can reach your local machine
This skill does NOT create or modify alerts. It sets up the listener infrastructure and then provides a summary of what the user needs to do to start receiving alerts.
OpenClaw users: If OpenClaw is running locally on the same machine, the webhook server can forward verified alert payloads to OpenClaw's Gateway (
/hooks/agent
), triggering an agent turn for each alert. Set the
OPENCLAW_GATEWAY_URL
env var to enable this. See the OpenClaw Integration section below.
Nansen智能告警支持webhook通道类型。当告警触发时,Nansen会向你的webhook URL发送携带JSON payload的HTTP POST请求。本技能会搭建以下内容:
  1. 本地HTTP服务器(Node.js,零外部依赖),用于接收并展示告警payload
  2. HMAC-SHA256签名验证,仅接收来自Nansen的合法payload
  3. 公共隧道,让Nansen的服务器可以访问你的本地机器
本技能不会创建或修改告警,它只会搭建监听基础设施,然后为用户提供后续开始接收告警所需的操作指引。
OpenClaw用户: 如果OpenClaw在同一台机器上本地运行,webhook服务器可以将验证通过的告警payload转发到OpenClaw的网关(
/hooks/agent
),每个告警触发一次agent轮询。设置
OPENCLAW_GATEWAY_URL
环境变量即可启用该功能,详见下方OpenClaw集成部分。

Security Warning

安全警告

Before proceeding, inform the user:
This skill starts an HTTP server on your machine and exposes it to the internet via a tunnel (ngrok or localtunnel). While the server only binds to localhost (
127.0.0.1
) — meaning no one on your local network can access it directly — the tunnel creates a public URL that anyone on the internet can send requests to.
Mitigations in place:
  • HMAC-SHA256 signature verification rejects all requests not signed by Nansen
  • 1 MB body size limit prevents memory abuse
  • Only
    POST /webhook
    and
    GET /health
    are accepted; everything else returns 404
You should be aware that:
  • The tunnel URL is publicly discoverable (ngrok URLs can be enumerated)
  • Unsigned requests still reach your machine — they're rejected, but the connection is made
  • Stop the tunnel when you're done to close the public endpoint
Wait for the user to confirm they want to proceed before continuing.
继续操作前,请告知用户:
本技能会在你的机器上启动一个HTTP服务器,并通过隧道(ngrok或localtunnel)将其暴露到公网。虽然服务器仅绑定到localhost(
127.0.0.1
)——意味着你本地网络的其他用户无法直接访问它——但隧道会生成一个公网URL,互联网上的任何人都可以向这个地址发送请求。
已内置的安全措施:
  • HMAC-SHA256签名验证会拒绝所有不是Nansen签名的请求
  • 1MB的请求体大小限制可以防止内存滥用
  • 仅接受
    POST /webhook
    GET /health
    请求,其他所有请求都返回404
你需要了解:
  • 隧道URL是公开可被枚举的(ngrok的URL可以被遍历发现)
  • 未签名的请求仍然会到达你的机器——虽然会被拒绝,但连接已经建立
  • 使用完成后请停止隧道,关闭公网端点
请等待用户确认要继续后再进行后续操作。

Execution Plan

执行计划

Follow these steps in order. Do not skip signature verification — it is mandatory.
按顺序执行以下步骤,不要跳过签名验证——这是强制要求。

Step 0: Choose a tunnel provider

步骤0:选择隧道提供商

Before starting, ask the user which tunnel provider they want to use:
ngrok (recommended)localtunnel
StabilityStable — persistent connections with keepaliveFlaky — free relay drops idle connections without warning, tunnels die randomly
Install
brew install ngrok
+ free account at ngrok.com
Zero install (
npx localtunnel
)
HTTPSYesYes
Auth requiredYes (free authtoken from ngrok.com)No
Recommend ngrok. localtunnel is convenient but unreliable — in testing, tunnels silently exit after minutes, causing alerts to fail with "503 Tunnel Unavailable". ngrok maintains stable connections.
Check if ngrok is available:
bash
which ngrok && ngrok version
If not installed, tell the user:
  1. brew install ngrok
    (or download from ngrok.com)
  2. Create a free account at ngrok.com and copy the authtoken
  3. ngrok config add-authtoken <token>
If the user prefers localtunnel or can't install ngrok, proceed with localtunnel but warn them that the tunnel may drop and they'll need to restart it and update their alert's webhook URL.
开始前,询问用户想要使用哪个隧道提供商:
ngrok(推荐)localtunnel
稳定性稳定——带心跳的持久连接不稳定——免费中继会无预警断开空闲连接,隧道会随机失效
安装要求
brew install ngrok
+ ngrok.com免费账号
零安装(
npx localtunnel
HTTPS支持
需要认证是(来自ngrok.com的免费authtoken)
推荐使用ngrok。localtunnel虽然方便但不可靠——测试中,隧道会在几分钟后静默退出,导致告警返回“503隧道不可用”,ngrok可以保持稳定连接。
检查ngrok是否可用:
bash
which ngrok && ngrok version
如果未安装,告知用户:
  1. brew install ngrok
    (或者从ngrok.com下载安装包)
  2. 在ngrok.com创建免费账号并复制authtoken
  3. ngrok config add-authtoken <token>
如果用户偏好localtunnel或者无法安装ngrok,可以使用localtunnel,但要警告用户隧道可能会断开,需要重启并更新告警的webhook URL。

Step 1: Generate a webhook secret

步骤1:生成webhook密钥

bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Store the output — you need it for both the server and the alert configuration. Never log or echo the secret after this point.
bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
存储输出结果,你在服务器和告警配置中都需要用到它。在此之后不要记录或打印密钥

Step 2: Write the webhook receiver script

步骤2:编写webhook接收脚本

Create
nansen-webhook-server.mjs
in the current working directory. Use only Node.js built-in modules (
node:http
,
node:crypto
). No
npm install
required.
Requirements — do not deviate:
RequirementDetail
Bind address
127.0.0.1
only — never
0.0.0.0
Default port
9477
(override via
PORT
env var)
Webhook path
POST /webhook
— reject all other method/path combos with 404
Health check
GET /health
→ 200
{"status":"ok"}
Signature verificationVerify
x-nansen-signature
header using HMAC-SHA256 with timing-safe comparison. Reject 401 on mismatch.
Secret validationExit on startup if
WEBHOOK_SECRET
env var is missing or < 16 chars
Payload loggingPretty-print valid JSON payloads to stdout with ISO timestamp
Request size limitReject bodies > 1 MB (413) to prevent memory abuse
Graceful shutdownHandle
SIGINT
and
SIGTERM
— close server, then exit
OpenClaw forwardingIf
OPENCLAW_GATEWAY_URL
env var is set, forward verified payloads to
<url>/hooks/agent
via POST. Include
OPENCLAW_AUTH_TOKEN
as Bearer token if set. Log forward success/failure.
No dependenciesOnly
node:http
,
node:https
, and
node:crypto
— nothing from npm
Signature verification — use timing-safe comparison:
javascript
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
  }
}
Full server template:
javascript
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');
});
在当前工作目录创建
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字符则退出
Payload日志带ISO时间戳,将合法JSON payload格式化打印到标准输出
请求大小限制拒绝大于1MB的请求体(413),防止内存滥用
优雅关机处理
SIGINT
SIGTERM
信号——关闭服务器后退出
OpenClaw转发如果设置了
OPENCLAW_GATEWAY_URL
环境变量,将验证通过的payload通过POST转发到
<url>/hooks/agent
,如果设置了
OPENCLAW_AUTH_TOKEN
则作为Bearer token携带,记录转发成功/失败状态
无依赖仅使用
node:http
node:https
node:crypto
——不使用任何npm包
签名验证——使用时序安全比较:
javascript
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
  }
}
完整服务器模板:
javascript
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');
});

Step 3: Start the server and tunnel

步骤3:启动服务器和隧道

Start the server:
bash
WEBHOOK_SECRET='<secret>' node nansen-webhook-server.mjs
Then start a public tunnel so Nansen's servers can reach it.
ngrok (recommended):
bash
ngrok http 9477
Get the public URL from ngrok's output or its local API:
bash
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))"
The webhook URL is
https://<subdomain>.ngrok-free.dev/webhook
.
localtunnel (fallback — unreliable):
bash
npx localtunnel --port 9477
Prints a URL like
https://xxx.loca.lt
. The webhook URL is
https://xxx.loca.lt/webhook
.
Warning: localtunnel's free relay silently drops connections after minutes. When this happens, all alerts fail with "503 Tunnel Unavailable" until you restart the tunnel and update the alert webhook URL. Use ngrok unless you have a reason not to.
Note: Tunnel URLs are ephemeral — they change every restart. For permanent setups, deploy the server to a host with a static URL.
启动服务器:
bash
WEBHOOK_SECRET='<secret>' node nansen-webhook-server.mjs
然后启动公共隧道让Nansen服务器可以访问它。
ngrok(推荐):
bash
ngrok http 9477
从ngrok的输出或者本地API获取公共URL:
bash
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(备选——不可靠):
bash
npx localtunnel --port 9477
会打印类似
https://xxx.loca.lt
的URL,webhook URL为
https://xxx.loca.lt/webhook
警告: localtunnel的免费中继会在数分钟后静默断开连接,发生这种情况时,所有告警都会返回“503隧道不可用”,直到你重启隧道并更新告警webhook URL。除非有特殊原因,否则请使用ngrok。
注意: 隧道URL是临时的——每次重启都会变化,如需永久部署,请将服务器部署到有静态URL的主机上。

Step 4: Provide a next-steps summary

步骤4:提供后续步骤总结

Do NOT create or modify any alerts. Instead, print a clear summary for the user explaining what was set up and what they need to do next.
The summary MUST include:
  1. Confirmation of what was created (the server script path and the generated secret)
  2. The commands to start the server and tunnel (with the actual secret filled in)
  3. The exact
    nansen alerts create
    or
    nansen alerts update
    command they should run, with the
    --webhook
    and
    --webhook-secret
    flags filled in with the tunnel URL and secret — but leave the alert-specific flags (
    --name
    ,
    --type
    ,
    --chains
    , etc.) as placeholders for the user to fill in
  4. A reminder that the server and tunnel must be running before the alert is created (Nansen validates the webhook endpoint on creation)
  5. A note that tunnel URLs are ephemeral and will change on restart
Example summary format:
undefined
不要创建或修改任何告警,而是为用户打印清晰的总结,说明已经搭建的内容,以及后续需要执行的操作。
总结必须包含:
  1. 确认创建的内容(服务器脚本路径和生成的密钥)
  2. 启动服务器和隧道的命令(填入实际的密钥)
  3. 他们需要运行的
    nansen alerts create
    nansen alerts update
    命令的准确格式,
    --webhook
    --webhook-secret
    参数填入隧道URL和密钥——但告警相关的参数(
    --name
    --type
    --chains
    等)留空让用户自行填写
  4. 提醒用户在创建告警前必须先启动服务器和隧道(Nansen在创建告警时会验证webhook端点)
  5. 说明隧道URL是临时的,重启后会变化
示例总结格式:
undefined

Webhook listener ready

Webhook监听器已就绪

Server script: ./nansen-webhook-server.mjs Port: 9477
服务器脚本: ./nansen-webhook-server.mjs 端口: 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.
undefined
  1. 启动服务器(保持这个终端窗口打开): WEBHOOK_SECRET='<actual-secret>' node nansen-webhook-server.mjs
  2. 在新终端中启动隧道: ngrok http 9477 # 推荐

    或: npx localtunnel --port 9477 (不可靠 — 隧道会静默断开)

  3. 创建指向你的webhook的告警(填写你的告警详情): 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...]
    或者将webhook添加到现有告警: nansen alerts update <alert-id>
    --webhook 'https://<your-tunnel-url>/webhook'
    --webhook-secret '<actual-secret>'
注意:每次重启隧道URL都会变化,如果重启了隧道请更新告警的webhook URL。
查看
nansen alerts create --help
获取每个告警类型的完整参数参考。
undefined

Security Checklist

安全清单

  • Always use a webhook secret — the server refuses to start without one
  • Always verify signatures — never accept unverified payloads
  • Bind to localhost only — the tunnel handles public exposure; direct
    0.0.0.0
    binding exposes you to unauthenticated traffic
  • Use HTTPS — both localtunnel and ngrok tunnel via HTTPS by default
  • Body size limit — the 1 MB cap prevents memory exhaustion from oversized requests
  • Timing-safe comparison — prevents timing side-channel attacks on the signature
  • 始终使用webhook密钥——没有密钥服务器会拒绝启动
  • 始终验证签名——永远不要接收未验证的payload
  • 仅绑定到localhost——隧道负责公网暴露;直接绑定
    0.0.0.0
    会让你暴露在未认证流量下
  • 使用HTTPS——localtunnel和ngrok默认都通过HTTPS隧道传输
  • 请求体大小限制——1MB上限可以防止超大请求导致内存耗尽
  • 时序安全比较——防止针对签名的时序侧信道攻击

Troubleshooting

故障排查

SymptomFix
"Invalid signature" on every requestEnsure the exact same secret is in
WEBHOOK_SECRET
and
--webhook-secret
"Failed to send welcome message" on alert createStart the server and tunnel before creating the alert
No alerts arrivingCheck
nansen alerts list --table
— is the alert enabled? Is the webhook URL correct (includes
/webhook
)?
Tunnel URL expired / tunnel diedRestart the tunnel, get the new URL, then
nansen alerts update <id> --webhook '<new-url>/webhook'
. If this keeps happening, switch from localtunnel to ngrok.
Port already in useSet a different port:
PORT=9478 WEBHOOK_SECRET='...' node nansen-webhook-server.mjs
and update the tunnel accordingly
症状解决方案
每次请求都返回“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 Integration

OpenClaw集成

If the user is running OpenClaw locally on the same machine, the webhook server can forward verified alert payloads to OpenClaw's Gateway, triggering an agent turn for each alert.
Flow:
Nansen → ngrok → webhook server (signature check) → OpenClaw /hooks/agent
如果用户在同一台机器上本地运行OpenClaw,webhook服务器可以将验证通过的告警payload转发到OpenClaw的网关,每个告警触发一次agent轮询。
流程:
Nansen → ngrok → webhook服务器(签名校验)→ OpenClaw /hooks/agent

Additional env vars

额外环境变量

VarRequiredPurpose
OPENCLAW_GATEWAY_URL
YesOpenClaw Gateway base URL (e.g.
http://localhost:3000
)
OPENCLAW_AUTH_TOKEN
If auth enabledBearer token for OpenClaw webhook endpoints
变量必填用途
OPENCLAW_GATEWAY_URL
OpenClaw网关基础URL(例如
http://localhost:3000
OPENCLAW_AUTH_TOKEN
开启认证时需要OpenClaw webhook端点的Bearer token

Start command (with OpenClaw forwarding)

启动命令(带OpenClaw转发)

bash
WEBHOOK_SECRET='<secret>' \
OPENCLAW_GATEWAY_URL='http://localhost:3000' \
OPENCLAW_AUTH_TOKEN='<token>' \
node nansen-webhook-server.mjs
The server logs both the alert payload and the OpenClaw forward status. If OpenClaw is unreachable, the forward fails silently (the alert is still logged to stdout).
bash
WEBHOOK_SECRET='<secret>' \
OPENCLAW_GATEWAY_URL='http://localhost:3000' \
OPENCLAW_AUTH_TOKEN='<token>' \
node nansen-webhook-server.mjs
服务器会同时记录告警payload和OpenClaw转发状态。如果OpenClaw不可达,转发会静默失败(告警仍会打印到标准输出)。

Ask the user

询问用户

Before enabling OpenClaw forwarding, ask:
  1. Is OpenClaw running locally? What port?
  2. Does their Gateway require auth? If so, what's the Bearer token?
If they don't know or aren't running OpenClaw, skip — the server works fine standalone.
在启用OpenClaw转发前,询问:
  1. OpenClaw是否在本地运行?端口是多少?
  2. 他们的网关是否需要认证?如果需要,Bearer token是什么?
如果他们不知道或者没有运行OpenClaw,跳过这部分——服务器可以独立正常运行。

Notes

注意事项

  • The server uses zero npm dependencies — only Node.js built-ins
  • One server can receive alerts from multiple Nansen alerts (as long as they share the same webhook secret)
  • For production use, deploy to a cloud host with a static URL and run behind a reverse proxy with TLS
  • The
    x-nansen-signature
    header format is
    sha256=<HMAC-SHA256(secret, rawBody)>
    — strip the
    sha256=
    prefix before comparing
  • 服务器零npm依赖——仅使用Node.js内置模块
  • 一个服务器可以接收多个Nansen告警的消息(只要它们使用相同的webhook密钥)
  • 生产环境使用时,部署到有静态URL的云主机,并且在带TLS的反向代理后运行
  • x-nansen-signature
    请求头格式是
    sha256=<HMAC-SHA256(secret, rawBody)>
    ——比较前要去掉
    sha256=
    前缀