# Webhook 推送订阅

> 原文地址: https://docs.blockvectra.com/zh/guides/webhook-push/

一个订阅包含一个 HTTPS 接收 URL、一把签名密钥、一组关注的 EVM 地址与必填的 `chains` 对象。地址对该对象中的每条链生效。通过 `x-api-key` 请求头调用 API，本账户任意有效 key 都能管理账户内的全部订阅。开始前先[获取 API key](https://blockvectra.com/zh/get-api-key/)。全部操作与 webhook schema 见[推送 OpenAPI](https://docs.blockvectra.com/openapi/push.yaml)。

## 创建订阅

调用 `GET /v1/push/chains` 获取可用链及其最小、默认、最大确认数。满足 `head - block + 1 >= confirmations` 时释放该区块；某条链传入 `{}` 即使用其默认确认数。至少指定一条链，后来新增的链不会自动加入已有订阅。

将以下示例保存为 `create.json`，把 URL 换成你的接收端，从链列表中选择需要的链。URL 必须使用端口 443 的 HTTPS 主机名，不能使用 IP 字面量，也不能包含用户信息或 fragment。

```json
{
  "url": "https://hooks.example.com/push",
  "chains": {
    "bsc_mainnet": {
      "confirmations": 1
    },
    "base_mainnet": {}
  }
}
```

在环境变量中设置 `BLOCKVECTRA_API_KEY`，再执行：

```bash
PUSH_URL='https://api.blockvectra.com/v1/push'
curl --fail-with-body -sS "$PUSH_URL/chains" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"
curl --fail-with-body -sS "$PUSH_URL/subscriptions" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d @create.json > subscription.json
```

创建成功返回 HTTP 201，订阅状态为 `online`，初始地址为空。安全保存数字 `id` 与 `secret`。密钥仅在创建与 `POST /subscriptions/{subscription_id}/rotate-secret` 时返回；轮换立即对所有链生效，没有双密钥重叠期。创建后不发送测试消息。

## 添加、删除与列出地址

将地址批次保存为 `addresses.json`，换成你要关注的地址：

```json
{
  "addresses": [
    "0x9725db73f2cd8657f3e1841e5689f210ee54a92d",
    "0x99d47bB552ae095159C251836De6A5d524076872"
  ]
}
```

将 `SUBSCRIPTION_ID` 设置为返回的订阅 ID：

```bash
curl --fail-with-body -sS "$PUSH_URL/subscriptions/$SUBSCRIPTION_ID/addresses/add" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' -d @addresses.json
curl --fail-with-body -sS "$PUSH_URL/subscriptions/$SUBSCRIPTION_ID/addresses" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"
curl --fail-with-body -sS "$PUSH_URL/subscriptions/$SUBSCRIPTION_ID/addresses/remove" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' -d @addresses.json
```

每次增删最多 10,000 个地址。输入须为小写地址或通过 EIP-55 校验的混合大小写地址，无效输入会拒绝整批。重复地址计为 `unchanged`，重复发送同一增删请求是安全的。列表使用 `limit` 与 `page_token` 分页，`next_page_token: null` 表示最后一页。

修改返回 `change_version`。轮询 `GET /subscriptions/{subscription_id}`，直到 `applied_version >= change_version`；每条链的 `applied_from_block` 表示生效块。新地址不追溯匹配历史事件。删地址从生效块起停止新的匹配，先前已匹配的事件仍可能送达。

通过 `PATCH /subscriptions/{subscription_id}` 的 JSON Merge Patch 修改 `url`、`key_id`、`status` 或 `chains`：链对象用于新增或修改，`null` 用于删链，至少保留一条链。`offline` 停止监听与投递并保留配置；改回 `online` 从当前生效块开始匹配，不补下线期间的事件。`DELETE` 永久删除订阅。

## 事件格式

每个 POST 的正文包含 `type: push.events`、`created_at` 与 `data`。`data` 包含 `subscription_id`、单条 `chain`、`complete_through_block` 与 `events`。按链记录进度：同一区块可能跨多条消息，单个事件的块号不是整块完成标记。

```json
{
  "type": "push.events",
  "created_at": "2026-10-02T03:00:05Z",
  "data": {
    "subscription_id": 48213,
    "chain": "bsc_mainnet",
    "complete_through_block": 64000121,
    "events": [
      {
        "id": "evt_ak5pcuhp27nqor33ghe5rksiu4",
        "type": "native.transfer",
        "ref": "eip155:56:0x7b19944dc683c33e8dedba259cb6939f7271f70f2eeb6ca5e456cccb57cc1eff:tx",
        "from": "0xe0a2100d7dad33f70c4bb765323cb96b2400c844",
        "to": "0x9725db73f2cd8657f3e1841e5689f210ee54a92d",
        "amount": "150000000000000000",
        "block_number": 64000120,
        "block_hash": "0x327892a3e5699a43981f0fbcc5e490628641d92c040eb0429fb550ba3a73c3bf",
        "block_timestamp": "2026-10-02T03:00:00Z",
        "tx_hash": "0x7b19944dc683c33e8dedba259cb6939f7271f70f2eeb6ca5e456cccb57cc1eff",
        "tx_index": 3,
        "matched": [
          {
            "address": "0x9725db73f2cd8657f3e1841e5689f210ee54a92d",
            "role": "to"
          }
        ]
      },
      {
        "id": "evt_qrirplrzttdrf4a5azk47zavei",
        "type": "token.transfer",
        "ref": "eip155:56:0x3be3448e6f54ebfa928d5997a0cb2d5e9d3186c5ebfa293ad45b7edc72483a76:7",
        "standard": "erc20",
        "token": "0x55d398326f99059ff775485246999027b3197955",
        "from": "0x0f94e5283c41c29a8f4dff8c17f68bdfb59f07df",
        "to": "0x99d47bb552ae095159c251836de6a5d524076872",
        "token_id": null,
        "amount": "25000000000000000000",
        "batch_index": null,
        "block_number": 64000121,
        "block_hash": "0x6a8146159162f182c091d17eac7d03e95dc92ce80de704c958ca2528306aff15",
        "block_timestamp": "2026-10-02T03:00:00Z",
        "tx_hash": "0x3be3448e6f54ebfa928d5997a0cb2d5e9d3186c5ebfa293ad45b7edc72483a76",
        "tx_index": 5,
        "log_index": 7,
        "matched": [
          {
            "address": "0x99d47bb552ae095159c251836de6a5d524076872",
            "role": "to"
          }
        ]
      },
      {
        "id": "evt_ccdxyd2g7pldjo4clal3oh63vi",
        "type": "log",
        "ref": "eip155:56:0x3be3448e6f54ebfa928d5997a0cb2d5e9d3186c5ebfa293ad45b7edc72483a76:8",
        "address": "0xb54ffbe723264b84cf74947127a6914cf87fc593",
        "topics": [
          "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925",
          "0x00000000000000000000000099d47bb552ae095159c251836de6a5d524076872",
          "0x000000000000000000000000b54ffbe723264b84cf74947127a6914cf87fc593"
        ],
        "data": "0x0000000000000000000000000000000000000000000000000000000000000000",
        "block_number": 64000121,
        "block_hash": "0x6a8146159162f182c091d17eac7d03e95dc92ce80de704c958ca2528306aff15",
        "block_timestamp": "2026-10-02T03:00:00Z",
        "tx_hash": "0x3be3448e6f54ebfa928d5997a0cb2d5e9d3186c5ebfa293ad45b7edc72483a76",
        "tx_index": 5,
        "log_index": 8,
        "matched": [
          {
            "address": "0x99d47bb552ae095159c251836de6a5d524076872",
            "role": "topic1"
          }
        ]
      }
    ]
  }
}
```

| 事件类型               | 处理内容                                                                                                                    |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `native.transfer`  | 涉及关注地址的成功顶层原生币转账，`amount` 为整数十进制字符串；不包含内部原生币转账。                                                                         |
| `token.transfer`   | 涉及关注地址的 ERC-20、ERC-721 与 ERC-1155 转账；读取 `standard`、`token`、`token_id`、`amount` 与 `batch_index`。ERC-1155 批量转账每个条目生成一个事件。 |
| `log`              | 其他日志中，关注地址为发出日志的合约或出现在 topics 1–3；读取 `address`、`topics`、`data` 与 `matched`。                                             |
| `subscription.gap` | `from_block` 到 `to_block` 区间无法有效投递，`reason: retention_expired`；使用 Data API 或 `eth_getLogs` 补扫。                          |
| `chain.incident`   | 已投递的 `from_block`–`to_block` 区间被替换；按 `tx_hash` 核对并处理后续规范链事件。                                                            |

同一订阅内按事件 `id` 去重，跨订阅按 `ref` 与 `type` 去重。忽略未知字段与未知事件类型。涉及资金操作时自行核验链上事实。

## 签名校验

请求头为 `webhook-id`、`webhook-timestamp`、`webhook-signature` 与 `bv-subscription-id`。只从你创建的订阅中选择密钥，拒绝未知 ID。先以请求原始字节校验 `webhook-id.webhook-timestamp.raw-body` 的 HMAC-SHA256，再解析 JSON。签名格式为 `v1,<base64>`，允许约五分钟时间戳偏差，并使用常量时间比较。

以下 Node.js 函数接收原始正文 `Buffer`、请求头与保存订阅 ID 到密钥映射的 `Map`：

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyPush(rawBody, headers, secrets) {
  const subscriptionId = headers['bv-subscription-id'];
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signature = headers['webhook-signature'];
  if ([subscriptionId, id, timestamp, signature].some(v => typeof v !== 'string')) return false;
  const secret = secrets.get(subscriptionId);
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;
  if (!/^\d{10}$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const match = /^v1,([A-Za-z0-9+/]{43}=)$/.exec(signature);
  if (!match) return false;
  const received = Buffer.from(match[1], 'base64');
  const expected = createHmac('sha256', Buffer.from(secret.slice(6), 'base64'))
    .update(`${id}.${timestamp}.`).update(rawBody).digest();
  return received.length === expected.length && timingSafeEqual(received, expected);
}
```

校验成功后解析正文、持久处理，并在 10 秒内返回 2xx。签名校验前不可相信订阅 ID 请求头。

## 投递、重试与重放

投递语义为至少一次。每个订阅的单条链按区块与块内顺序投递，失败批次会阻塞该链后续事件。不同链进度独立，可以并发 POST。同一批重试保留 `webhook-id`，但批次变化可能产生新 ID，因此按事件去重，不按批次去重。

10 秒内返回任意 2xx 表示已持久处理。重定向不跟随，3xx 与 410 都按失败处理。失败后的重试间隔依次为立即、5 秒、30 秒、2 分钟、10 分钟、30 分钟、1 小时，之后每小时重试。429 的 `Retry-After` 可延长等待，按最长一小时处理。投递停住时查看每条链的 `condition`、`last_error` 与 `next_attempt_at`。`condition` 包括 `receiver_failing`、`insufficient_balance` 与 `key_revoked`；最后一种需 PATCH `key_id` 到本账户另一把有效 key。

未送达事件超出保留窗口后过期，产生 `subscription.gap`。`POST /subscriptions/{subscription_id}/replay` 接收 `chain` 与 `from_block`，参考 `GET /push/chains` 的 `replayable_from_block` 与订阅进度选择起点。重放投递已匹配的事件，不能补地址或链加入前的事件。影响已投递区块的重组会产生 `chain.incident`，随后重投规范链事件；链停止时查看链列表的 `halted`。

通过 `GET /subscriptions/{subscription_id}/events?chain=...` 查询已送达数据事件，可附加 `from_block`、`to_block`、`limit` 与 `page_token`。历史行包含 `event`、`replay_epoch`、`orphaned` 与 `delivered_at`，`orphaned: true` 表示所在区块后来被替换。区块范围错误与重试处理见[错误参考](https://docs.blockvectra.com/zh/errors/)。

## 计费与示例

权重来自 `GET /v1/plans`。已送达数据事件、成功历史请求与地址日分别计价；除历史查询外的管理调用、控制事件、失败投递与自动重试免费。每个已送达事件收取一次费用；客户重放与规范链事件重投会产生新的投递费用。

地址费按每个订阅在 UTC 日内在线期间的最大地址数计算，再扣除由各订阅共享的账户免费地址额度，较早创建的订阅优先使用。同一地址放在两个订阅中计为两份；增加链影响事件费，不增加地址费。整日离线的订阅没有该日地址费。

| 用量 | 计费单位 | CU |
| --- | --- | --- |
| `push.address_day` | 计费地址日 | 33 |
| `push.history` | 成功的历史查询请求 | 25 |
| `push.log` | 已送达的数据事件 | 150 |
| `push.native_transfer` | 已送达的数据事件 | 150 |
| `push.token_transfer` | 已送达的数据事件 | 150 |

每账户每 UTC 日免费地址数：1000

每账户每 UTC 日的免费地址额度，由所有订阅组共享，与套餐无关。对每个组，统计其在该日在线期间的最大地址数；按组 ID 升序分配额度。同一地址在两个组中计为两份；组内链的数量不会使地址数成倍增加。整日离线或已删除的组不计入。对每个组，扣除分配给它的额度后，将剩余地址数乘以 `method_weights` 中的 `push.address_day` CU 权重。当前配置的额度来自地址日计费所用的同一定价策略；它不是账户容量上限，也不是每个组各自独立的额度。

示例：10 个已送达的 native.transfer 事件、2 次成功历史查询与 10 个计费地址日，消耗 10 × 150 + 2 × 25 + 10 × 33 = 1880 CU。计费地址日已扣除账户免费地址额度。

CU 计量与换算见[计费规则](https://docs.blockvectra.com/zh/guides/billing-rules/)及[定价页](https://blockvectra.com/zh/pricing/)。
