# 區塊鏈 Webhook 設定：簽章驗證、去重與重放

> Source: https://docs.blockvectra.com/zh-hant/guides/webhook-push/

關注一個 EVM 錢包地址，在你的 HTTPS 端點接收其原生幣轉帳、代幣轉帳與相符的合約日誌，用於錢包活動通知或智慧合約事件監控。開發者與 AI Agent 使用同一套 HTTP 訂閱 API。對於 ERC-20 USDT / USDC 付款通知，請參考[穩定幣收款接收端](https://docs.blockvectra.com/en/guides/stablecoin-payments/#receive-payments-with-webhooks)。

## 這篇指南協助你完成的任務

* [接收錢包地址活動](#connect-wallet-address-activity)：透過建立帶驗證的訂閱、新增關注地址並驗證傳入事件。
* [監控相符的合約日誌](#event-format)：檢查關注地址的 `log` 事件，並在接收端篩選 `address`、`topics` 與 `data`。
* [復原中斷的送達](#delivery-retries-and-replay)：檢查訂閱進度並重放保留的相符事件，然後回填重放範圍之外的缺口。

一個訂閱結合了一個 HTTPS 接收 URL、一把簽名金鑰、多個關注的 EVM 地址以及一個必填的 `chains` 物件。地址適用於該物件中的每條鏈。使用帶 `x-api-key` 標頭的 API；帳戶中的任何有效 key 都可以管理其全部訂閱。開始之前請先[取得 API key](https://blockvectra.com/en/get-api-key/)。[推送 OpenAPI](https://docs.blockvectra.com/openapi/push.yaml) 列出了每項操作與 Webhook 結構定義。

## 接入錢包地址活動

1. 部署一個接收端，以[驗證原始請求主體](#verify-signatures)，按 `id` 持久化儲存事件，並在 10 秒內確認處理。
2. 讀取 `GET /v1/push/chains`，然後使用你的 HTTPS URL 與所選鏈[建立訂閱](#create-a-subscription)。儲存回傳的 `id` 與 `secret`。
3. [新增錢包地址](#add-and-list-addresses)。等待 `applied_version >= change_version`，並記錄每條鏈的 `applied_from_block`；比對從該處開始。
4. 處理轉帳與日誌，並[復原缺口或被替換的區塊](#delivery-retries-and-replay)。在付款處理中使用通知前，先篩選代幣合約、收款地址與整數金額。

## 選擇 Webhook、WebSocket 或輪詢

* **Webhook** 將關注地址的事件發送到 HTTPS 接收端，支援送達重試與保留相符事件的重放。
* **[WebSocket](https://docs.blockvectra.com/en/guides/websocket-subscriptions/)** 透過持續連線串流傳輸 `newHeads` 與篩選後的 `logs`。斷線後重新連線、重新訂閱並查詢錯過的區塊。
* **[輪詢](https://docs.blockvectra.com/en/guides/stablecoin-payments/)** 使用你自己的游標在有界區塊範圍內查詢 `eth_getLogs`；用於監控付款或回填缺失的日誌。

檢查 `GET /v1/chains` 中的 `ws` 與 `subscriptions` 以確認 WebSocket 支援。如果 `ws` 為 false，只要該鏈出現在帶驗證的 `GET /v1/push/chains` 清單中，地址 Webhook 仍然可用。僅具備 RPC 支援並不代表支援推送。

## 地址容量

自助方案支援每個訂閱最多 1,000,000 個地址，註冊即可使用。一個訂閱涵蓋多條鏈與一個接收 URL。企業容量支援每個訂閱 10,000,000 / 100,000,000 個地址；[聯絡我們以開通](https://blockvectra.com/en/contact/)。開發者與 AI Agent 享有相同的容量方案與價格。兩個層級均沿用[定價頁](https://blockvectra.com/en/pricing/)中顯示的相同地址日與已送達事件費率。

## 建立訂閱

讀取 `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" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"
```

每次新增呼叫最多接受 10,000 個地址。輸入地址必須為全小寫或有效的 EIP-55 混合大小寫；無效輸入會拒絕整批。重複的地址計為 `unchanged`，因此重複發送相同的新增請求是安全的。地址清單使用 `limit` 與 `page_token`；`next_page_token: null` 標記為最後一頁。

新增地址會回傳 `change_version`。輪詢或檢查 `GET /subscriptions/{subscription_id}` 直到 `applied_version >= change_version`；變更通常約需 1 秒套用。每條鏈的 `applied_from_block` 標示出開始比對鏈上交易與日誌的生效區塊。新地址不會回溯比對。

建立訂閱回傳 HTTP 201 用於確認訂閱資源已建立；HTTP 201 並不代表你的接收端已收到任何 Webhook 推送。平台在建立或註冊地址時不會發送驗證或測試訊息。你必須等待關注地址與鏈上發生相符的鏈上活動，才能在接收端驗證送達。

## 事件格式

每個 POST 均包含 `type: push.events`、`created_at` 與 `data`。`data` 包含 `subscription_id`、一條 `chain`、`complete_through_block` 與 `events`。按鏈記錄進度：一個區塊可能跨越多條訊息，因此個別事件的區塊號不是完成標記。每條訊息最多包含 1,000 個事件、1 MiB 與 50 個區塊。

```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_payvsqb6ogymhmehrs2wl5xcky",
        "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_lgcdattb6l2k3ejuhe4mtdljkm",
        "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_sgliw3ficdf6gaa6zzx4ew6vni",
        "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.reorg`      | 免費重組通知：已送達的 `from_block`–`to_block` 區塊已被替換。按 `ref` 標記或丟棄其舊事件，然後保留自動重新送達的規範鏈事件並按 `id` 去重。                                |

在單一訂閱內，按事件 `id` 去重；跨訂閱時使用 `ref` 與 `type`。忽略未知欄位與事件類型。在採取財務動作之前，請核實鏈上事實。

## 驗證簽章

標頭包含 `webhook-id`、`webhook-timestamp`、`webhook-signature` 與 `bv-subscription-id`。僅從你建立的訂閱中挑選金鑰；拒絕未知 ID。在解析 JSON 之前，先以請求原始主體的位元組對 `webhook-id.webhook-timestamp.raw-body` 驗證 HMAC-SHA256。簽章為 `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 標頭是不可信任的。

## 驗證首個事件

保持訂閱為線上狀態（online）。地址變更套用後，等待相符的鏈上活動，並確認你的接收端驗證並持久儲存該事件。

## 驗證後停止監聽

<a id="stop-listening-and-clean-up" />

若要停止關注地址，請將要移除的地址儲存在 `addresses.json` 中，並呼叫 `POST /subscriptions/{subscription_id}/addresses/remove`：

```bash
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 個地址。目前未被關注的地址計為 `unchanged`。此呼叫回傳 `change_version`。一旦 `applied_version >= change_version`，從該生效區塊起不再比對被移除的地址。先前已比對的事件（在傳輸中、重試中或已排隊）仍會送達；已送達的事件不會撤回。

若要暫時暫停監聽而不刪除設定或地址，請將 `status` 設定為 `offline`：

```bash
curl --fail-with-body -sS -X PATCH "$PUSH_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"status":"offline"}'
```

`offline` 訂閱會停止監聽與送達，從比對索引中卸載地址，且在整天維持離線的任何完整 UTC 日內不產生地址費用。所有設定（URL、金鑰、地址、鏈與確認數）均保留。修補 `{"status":"online"}` 會從目前生效區塊恢復監聽，且不會回填離線期間的內容。

在 `PATCH /subscriptions/{subscription_id}` 上使用 JSON Merge Patch 來變更 `url`、`key_id`、`status` 或 `chains`：鏈物件會新增或更新它，而 `null` 則會將其移除。必須至少保留一條鏈。若要永久刪除訂閱：

```bash
curl --fail-with-body -sS -X DELETE "$PUSH_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"
```

`DELETE` 會永久移除訂閱，立即停止所有鏈上的送達，並銷毀金鑰與地址。

## 送達、重試與重放

送達語意為至少一次。每個訂閱的鏈按區塊與區塊內的位置排序；失敗的批次會阻塞該鏈上的後續事件。不同的鏈具有獨立的進度，且可以並行 POST。相同批次的重試會保留 `webhook-id`，但已變更的批次可能會有新的 ID：應按事件去重，而不是按批次去重。

10 秒內的任何 2xx 均確認為持久化處理。不跟隨重新導向；3xx 與 410 均視為失敗。失敗後，重試依序遵循立即、5 秒、30 秒、2 分鐘、10 分鐘、30 分鐘與 1 小時的間隔，之後每小時重試。429 的 `Retry-After` 可以將等待時間延長至最多一小時。送達停止時，請檢查每條鏈的 `condition`、`last_error` 與 `next_attempt_at`。狀況包含 `receiver_failing`、`insufficient_balance` 與 `key_revoked`；最後一種需要將 `key_id` 修補至另一個有效的帳戶 key。

未送達的事件在超出保留時間窗後過期並產生 `subscription.gap`。`POST /subscriptions/{subscription_id}/replay` 接收 `chain` 與 `from_block`；請參考 `GET /push/chains` 中的 `replayable_from_block` 與訂閱的進度。重放會送達現有的相符事件，且無法復原在新增地址或鏈之前的事件。

`chain.reorg` 通知你已送達的區塊已被替換；它並不表示送達缺口。淺於你的確認數的重組是不可見的。對於影響已送達區塊深度達 1,024 個區塊的重組，規範鏈事件會自動以新的 `id` 重新送達。依 `ref` 標記或丟棄被替換的事件，保留規範鏈事件並按 `id` 去重；對於付款記錄，依 `ref` 與 `tx_hash` 進行對帳。更深層的重組會使鏈暫停：請檢查 `GET /push/chains` 中的 `halted`；鏈恢復後將自動重新送達規範鏈事件。該控制事件不會推進 `complete_through_block`。

使用 `GET /subscriptions/{subscription_id}/events?chain=...` 查詢已送達的資料事件，可選擇性新增 `from_block`、`to_block`、`limit` 與 `page_token`。歷史記錄列包含 `event`、`replay_epoch`、`orphaned` 與 `delivered_at`；`orphaned: true` 標記該區塊稍後被替換。歷史查詢准入可能回傳 402 `insufficient_balance`（`data.reason`：`balance_exhausted` 或 `free_grant_exhausted`）、403 `key_cap_exhausted`（`data.cu_cap`），或 429 `rate_limited`（`key_rate_limit` 或 `free_plan_call_limit`）。429 `cost_exceeds_burst` 帶有原因 `request_exceeds_burst` 與 `data.max`：在重試之前請提高突發容量。有關無效範圍與重試指引，請參閱[錯誤處理](https://docs.blockvectra.com/en/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/en/guides/billing-rules/)與[定價頁面](https://blockvectra.com/en/pricing/)。

## 相關資源

* 在[區塊鏈 Webhook API 概覽](https://blockvectra.com/en/webhooks/)中比較支援的事件、鏈覆蓋範圍與定價。
