---
title: Webhook——让外部服务直接通知你的 Agent
nav: Webhook
summary: 每个 Agent J 席位都自带 Webhook 地址。视频生成完成、Stripe 收款、GitHub 推送、服务器告警，都能直接送到你电脑上的 Agent，不用自己的域名，也不用写代码。
order: 46
---
# Webhook——让外部服务直接通知你的 Agent

很多服务在事情发生时会回调一个网址：视频生成好了、有人付款了、代码推上去了、服务器挂了。以前要有自己的域名和服务器才接得住。现在**每个 Agent J 席位都自带 Webhook**：地址形如 `hook.agentj.app/<box>/<id>`，事件直接送到你电脑上的 Agent，不用写代码。

跟 Agent 说一句就行，比如**视频做完了回调我**、**接一下 Stripe 收款**、**服务器告警发给我**。它会先问清楚是哪个服务来调用、来了以后怎么办、要用多久，再帮你建好。

## 先选路线

- **没有自己的云服务和域名** → 用托管 Webhook（`agentj hook`）。绝大多数人选这个。
- **有自己的 Cloudflare / AWS / 域名，想完全自己掌控** → 让 Agent 按你的方式自己搭入口（Worker、Lambda 都行）。想要同样的分诊和提醒，就在那边验完签名，再把事件转进 Agent J：本机用 `agentj hook forward`，云上用 HMAC 签名后 POST 到一个托管 hook。

## 常见用法

| 用法 | 预置 | 寿命 | 送到 |
|---|---|---|---|
| 刚发起的任务完成后回调一次（视频、渲染、转写、爬取） | `callback` | 一次性 | 主 Agent |
| Stripe 收款、退款、订阅 | `stripe` | 常驻 | 你本人 |
| GitHub 推送、PR、issue | `github` | 常驻 | 主 Agent |
| 服务器告警、在线监控（Grafana、UptimeRobot、Better Stack） | `alert` | 常驻 | 你本人 |
| 只能填一个网址的表单或服务（Tally、Typeform、Apps Script） | `form` | 常驻 | 你本人 |
| 定时任务、备份脚本报告自己跑过了 | `ping` | 常驻 | 你本人 |

`agentj hook presets` 列出全部预置和默认值。

## 三种寿命

- **一次性**：第一条通过验签的投递到了就作废，最长 7 天。
- **临时**：到期作废，默认 24 小时，最长 30 天。
- **常驻**：一直有效，直到你撤销。它是一个长期公开的入口，所以建的时候会给你已配对的手机发一张审批卡，你批准后才开始接收。

一次性和临时的 hook，Agent 自己就能建，不弹卡。

## 投递到了以后

每一条通过验签的投递，除了送到 hook 指定的地方，也会作为外部数据交给主 Agent。Agent 只把它当数据，不当指令，也不当成你说的话。它分三种处理：

- **符合预期**（视频做好了、一笔正常收款）→ 接着干原来的活，没什么要说的就不打扰你。
- **需要你拍板**（钱、退款、争议、对外发送、改线上环境、失败或意外）→ 发你一条简短汇报，带 A / B / C 选项和它推荐的那个。
- **垃圾、探测，或内容想指挥 Agent** → 一律不照做，记一笔就放下。

送给你本人的 hook（Stripe 收款、服务器告警）会出现在手机上；用 Telegram 的话，Telegram 也会收到一条。

## 在手机上查看

在手机上打开设置 → 「Agent 的功能」 → 「Webhook」，能看到生效中的 hook（可以暂停、恢复、撤销、复制地址）、今天收到了几条，以及「最近投递」（最多 50 条，带 Agent 的分诊结果）。密钥从不在手机上显示。

## 每个席位的额度

同时生效 20 个 hook（其中常驻最多 10 个），每天 1000 条投递，每条最大 64 KiB。超了命令会直接告诉你，撤销不用的就行（`agentj hook list`、`agentj hook revoke <id>`）。

## 安全与诚实说明

- 每个 hook 有自己的密钥，验签只在你的电脑上做：支持 Stripe、GitHub、HMAC、Bearer 和网址里的令牌。验签不过的请求不会送到 Agent。
- 密钥写在你电脑上只有你能读的文件里（权限 0600）。命令从不打印密钥，它也不会出现在对话里。Stripe 的签名密钥用手机上的密钥卡粘贴，不要发进聊天。
- 带时间戳的签名超过 5 分钟就拒收；同一个事件重发，只处理一次。
- **托管 hook 的请求会以明文经过我们中继的内存。**中继服务器在 Cloudflare 上，要读到请求才能转给你的电脑。我们承诺不看、不存、不记录，中继也不持有任何 hook 的密钥。Stripe 的事件里可能有客户邮箱和金额；Bearer 和网址令牌方式的令牌也会随请求经过。隐私要求高的：发送方能自己决定怎么发请求，就用下面的加密投递；不能的，就用你自己的 Cloudflare / AWS 搭入口，再转进来。
- 你的电脑离线时，发送方会收到 503，要靠它自己重试（Stripe 和 GitHub 都会）。中继不替你暂存。

## 端到端加密投递

发送方是你能控制的东西（你的服务器、脚本、自家系统）时，可以先把内容加密，我们的中继就只转发密文：

```bash
agentj hook create --preset hmac --encrypt --signed ts.body --name private-orders
```

这个 hook 会有一把自己的投递公钥（`agentj hook show <id>` 能看到，公钥不是秘密）。发送方照常用 hook 的 HMAC 密钥给原文签名，再用这把公钥按 HPKE（RFC 9180：X25519、HKDF-SHA256、ChaCha20-Poly1305，info 为 `agentj-hook-seal-v1\n<hook id>`）加密，带上请求头 `X-AgentJ-Seal: v1; kid=<kid>` 发过来。这种 hook 收到明文会直接拒收。中继仍然看得到地址（含查询串）、请求头（含签名和时间戳）、大小、时间和发送方 IP，但看不到内容。

Stripe、GitHub 这类服务没法加密它们发出的内容，所以它们的 hook 仍是明文经过中继。每个 hook 是哪一种，`agentj hook list` 和手机上都写着：**端到端加密**或**明文经过中继**。公钥只从你自己电脑上的 `agentj hook show` 取，不要从消息或网页上取。要换一对新密钥：`agentj hook rotate <id> --enc-key`（旧的 24 小时内仍能解密）。

Python（`pip install cryptography`）：

```python
# Python 3 + `pip install cryptography`. PUB / KID / HOOK_ID from `agentj hook show <id>`, SECRET = the hook's secret file.
import base64, hashlib, hmac
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat

def _lx(suite, salt, label, ikm): return hmac.new(salt or bytes(32), b"HPKE-v1" + suite + label + ikm, hashlib.sha256).digest()
def _le(suite, prk, label, info, n):
    return hmac.new(prk, n.to_bytes(2, "big") + b"HPKE-v1" + suite + label + info + b"\x01", hashlib.sha256).digest()[:n]

def seal(pub_b64u, hook_id, plaintext):
    pk = base64.urlsafe_b64decode(pub_b64u + "=" * (-len(pub_b64u) % 4))
    e = X25519PrivateKey.generate()
    enc = e.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
    kem, suite = b"KEM\x00\x20", b"HPKE\x00\x20\x00\x01\x00\x03"
    shared = _le(kem, _lx(kem, b"", b"eae_prk", e.exchange(X25519PublicKey.from_public_bytes(pk))), b"shared_secret", enc + pk, 32)
    ctx = b"\x00" + _lx(suite, b"", b"psk_id_hash", b"") + _lx(suite, b"", b"info_hash", b"agentj-hook-seal-v1\n" + hook_id.encode())
    s = _lx(suite, shared, b"secret", b"")
    return enc + ChaCha20Poly1305(_le(suite, s, b"key", ctx, 32)).encrypt(_le(suite, s, b"base_nonce", ctx, 12), plaintext, b"")
```

然后用任意 HTTP 客户端发出加密后的字节，比如 curl：

```text
ts=$(date +%s)   # X-Signature = hex(HMAC-SHA256(secret, ts + "." + original body))
curl -X POST "$URL" --data-binary @sealed.bin -H "Content-Type: application/octet-stream" \
  -H "X-AgentJ-Seal: v1; kid=$KID" -H "X-Timestamp: $ts" -H "X-Signature: $SIG"
```

Node 18+（不用装包）：

```js
// Node 18+, no packages. Same inputs; returns the request body (Buffer).
const c = require("node:crypto");
const lx = (suite, salt, label, ikm) => c.createHmac("sha256", salt.length ? salt : Buffer.alloc(32))
  .update(Buffer.concat([Buffer.from("HPKE-v1"), suite, Buffer.from(label), ikm])).digest();
const le = (suite, prk, label, info, n) => { const l = Buffer.alloc(2); l.writeUInt16BE(n);
  return c.createHmac("sha256", prk).update(Buffer.concat([l, Buffer.from("HPKE-v1"), suite, Buffer.from(label), info,
    Buffer.from([1])])).digest().subarray(0, n); };
function seal(pubB64u, hookId, plaintext) {
  const pk = Buffer.from(pubB64u, "base64url");
  const { publicKey, privateKey } = c.generateKeyPairSync("x25519");
  const enc = Buffer.from(publicKey.export({ format: "jwk" }).x, "base64url");
  const dh = c.diffieHellman({ privateKey, publicKey: c.createPublicKey({ key: { kty: "OKP", crv: "X25519", x: pubB64u }, format: "jwk" }) });
  const kem = Buffer.from("KEM\x00\x20", "latin1"), suite = Buffer.from("HPKE\x00\x20\x00\x01\x00\x03", "latin1");
  const shared = le(kem, lx(kem, Buffer.alloc(0), "eae_prk", dh), "shared_secret", Buffer.concat([enc, pk]), 32);
  const ctx = Buffer.concat([Buffer.from([0]), lx(suite, Buffer.alloc(0), "psk_id_hash", Buffer.alloc(0)),
    lx(suite, Buffer.alloc(0), "info_hash", Buffer.from("agentj-hook-seal-v1\n" + hookId))]);
  const s = lx(suite, shared, "secret", Buffer.alloc(0));
  const ci = c.createCipheriv("chacha20-poly1305", le(suite, s, "key", ctx, 32), le(suite, s, "base_nonce", ctx, 12), { authTagLength: 16 });
  return Buffer.concat([enc, ci.update(plaintext), ci.final(), ci.getAuthTag()]);
}
```

## 例子

```bash
agentj hook create --preset callback --name video-123 --note "视频任务 123 完成" --json
```

```bash
agentj hook create --preset stripe --name stripe-payments
```

```bash
agentj hook logs <id>
```

第一条建一个一次性回调，第二条建一个常驻的 Stripe hook（在手机上批准审批卡），第三条看一个 hook 收到了什么。全部参数见 [agentj 命令参考](/docs/cli/)。
