# ブロックチェーン Webhook の設定：署名検証、重複排除、再送

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

EVM ウォレットアドレスを監視し、そのネイティブ通貨転送、トークン転送、一致するコントラクトログを HTTPS エンドポイントで受信して、ウォレット活動の通知やスマートコントラクトイベントの監視に利用できます。開発者と AI エージェントは同じ HTTP 購読 API を使用します。ERC-20 USDT / USDC の入金通知については、[ステーブルコイン入金の受信](https://docs.blockvectra.com/en/guides/stablecoin-payments/#receive-payments-with-webhooks)を参照してください。

## このガイドでできること

* 認証付きの購読を作成し、監視対象アドレスを追加して、受信イベントを検証することで、[ウォレットアドレスの活動を受信](#connect-wallet-address-activity)します。
* 監視対象アドレスの `log` イベントを調べ、受信側で `address`、`topics`、`data` をフィルタリングして、[一致するコントラクトログを監視](#event-format)します。
* 購読の進捗を確認し、保持されている一致イベントを再送してから、再送可能な期間外の欠落をバックフィルすることで、[中断した配信を復旧](#delivery-retries-and-replay)します。

購読は、1 つの HTTPS 受信 URL、署名用シークレット、監視対象の EVM アドレス、必須の `chains` オブジェクトを組み合わせたものです。アドレスは、そのオブジェクト内のすべてのチェーンに適用されます。API は `x-api-key` ヘッダーを付けて使用してください。アカウント内の有効なキーであれば、どのキーでもそのアカウントのすべての購読を管理できます。開始前に [API key を取得](https://blockvectra.com/en/get-api-key/)してください。[Push 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` を照会します。入金の監視や欠落したログのバックフィルに使用してください。

WebSocket の対応状況は、`GET /v1/chains` の `ws` と `subscriptions` を確認してください。`ws` が false でも、そのチェーンが認証付きの `GET /v1/push/chains` の一覧に含まれていれば、アドレス Webhook を利用できます。RPC に対応しているだけでは、Push に対応しているとは限りません。

## アドレス容量

セルフサービスは購読あたり最大 1,000,000 アドレスに対応し、登録後すぐに利用できます。1 つの購読で、1 つの受信 URL を使って複数のチェーンをカバーします。エンタープライズの容量は購読あたり 10,000,000 / 100,000,000 アドレスです。[有効化についてお問い合わせ](https://blockvectra.com/en/contact/)ください。開発者と AI エージェントは、同じ容量プランと料金を利用できます。両プランとも、[料金](https://blockvectra.com/en/pricing/)に記載された同じアドレス日と配信済みイベントの単価が適用されます。

## 購読を作成する

`GET /v1/push/chains` を読み取り、利用可能なチェーンと、確認数の最小値、デフォルト値、最大値を確認してください。ブロックは `head - block + 1 >= confirmations` になると配信対象になります。各チェーンには `{}` を指定することでデフォルト値を使用できます。少なくとも 1 つのチェーンが必要です。新しいチェーンが既存の購読に自動で追加されることはありません。

以下の例を `create.json` として保存し、URL を受信先の URL に置き換えて、チェーン一覧からチェーンを選択してください。URL はポート 443 の HTTPS を使用し、IP アドレスのリテラルではなくホスト名を指定し、ユーザー情報やフラグメントを含めないでください。

```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` が返されます。`applied_version >= change_version` になるまで `GET /subscriptions/{subscription_id}` をポーリングするか確認してください。変更は通常、約 1 秒で適用されます。各チェーンの `applied_from_block` は、オンチェーンのトランザクションとログの照合が始まる有効開始ブロックを示します。新しいアドレスが過去にさかのぼって照合されることはありません。

購読の作成時には、購読リソースが作成されたことを確認するために HTTP 201 が返されます。HTTP 201 は、受信先で Webhook プッシュを受信したことを意味しません。プラットフォームは、作成時やアドレス登録時に検証メッセージやテストメッセージを送信しません。受信先への配信を検証するには、監視対象のアドレスとチェーンで、一致するオンチェーン活動が発生するまで待つ必要があります。

## イベント形式

すべての POST には `type: push.events`、`created_at`、`data` が含まれます。`data` には `subscription_id`、1 つの `chain`、`complete_through_block`、`events` が含まれます。進捗はチェーンごとに記録してください。1 つのブロックが複数のメッセージにまたがることがあるため、個々のイベントのブロック番号は完了を示しません。各メッセージの上限は 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` は整数の 10 進文字列です。内部のネイティブ通貨転送は含まれません。                                                              |
| `token.transfer`   | 監視対象アドレスが関わる ERC-20、ERC-721、ERC-1155 の転送。`standard`、`token`、`token_id`、`amount`、`batch_index` を確認してください。ERC-1155 のバッチ転送では、項目ごとに 1 つのイベントが生成されます。 |
| `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>` です。タイムスタンプのずれは約 5 分まで許容し、定数時間で比較してください。

次の Node.js 関数は、`Buffer` としての生のボディ、リクエストヘッダー、購読 ID と保存済みシークレットを対応付けたマップを受け取ります。

```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 ヘッダーを信頼できません。

## 最初のイベントを検証する

購読をオンラインに保ってください。アドレスの変更が適用されたら、一致するオンチェーン活動を待ち、受信側でイベントを検証して永続的に保存できることを確認してください。

## 検証後に監視を停止する

<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 すると、現在の有効開始ブロックから監視を再開し、オフラインだった期間はバックフィルしません。

`PATCH /subscriptions/{subscription_id}` で JSON Merge Patch を使用すると、`url`、`key_id`、`status`、`chains` を変更できます。チェーンオブジェクトを指定すると、そのチェーンを追加または更新し、`null` を指定すると削除します。少なくとも 1 つのチェーンを残す必要があります。購読を完全に削除するには、次を実行してください。

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

`DELETE` は購読を完全に削除し、すべてのチェーンで配信を即座に停止して、シークレットとアドレスを破棄します。

## 配信、再試行、再送

配信は少なくとも 1 回行われます。各購読のチェーン内では、ブロックとブロック内の位置の順に配信されます。バッチが失敗すると、そのチェーンの後続イベントは配信が止まります。異なるチェーンの進捗は独立しており、並行して POST できます。同一バッチの再試行では `webhook-id` が維持されますが、バッチが変更されると新しい ID になることがあります。重複排除はバッチではなくイベントに対して行ってください。

10 秒以内に返される 2xx は、処理結果が永続化されたことの確認になります。リダイレクトには追従しません。3xx と 410 は失敗です。失敗後は、即時、5 秒、30 秒、2 分、10 分、30 分、1 時間の間隔で再試行し、その後は毎時間再試行します。429 の `Retry-After` により、待機時間が最大 1 時間まで延びることがあります。配信が停止した場合は、各チェーンの `condition`、`last_error`、`next_attempt_at` を確認してください。条件は `receiver_failing`、`insufficient_balance`、`key_revoked` です。最後の条件では、`key_id` をアカウント内の別の有効なキーに PATCH する必要があります。

未配信イベントは保持期間を過ぎると期限切れになり、`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` から取得します。配信済みデータイベント、成功した履歴リクエスト、アドレス日には、それぞれ別の重み付けが適用されます。履歴以外の管理コール、制御イベント、失敗した配信、自動再試行は無料です。配信された各イベントは 1 回課金されます。利用者による再送と正規チェーンのイベントの再配信には、新たな配信料金が発生します。

アドレス課金は、UTC 日に各購読がオンラインだった間の最大アドレス数に基づき、購読間で共有するアカウントの無料アドレス枠を差し引いた後に適用されます（古い購読から順に割り当てます）。同じアドレスが 2 つの購読にある場合は 2 回数えます。チェーンを追加するとイベント料金は変わりますが、アドレス料金は変わりません。UTC 日の丸一日を通してオフラインだった購読には、アドレス料金は発生しません。

| 利用量 | 課金単位 | CU |
| --- | --- | --- |
| `push.address_day` | 課金対象のアドレス日 | 33 |
| `push.history` | 成功した履歴リクエスト | 25 |
| `push.log` | 配信済みデータイベント | 150 |
| `push.native_transfer` | 配信済みデータイベント | 150 |
| `push.token_transfer` | 配信済みデータイベント | 150 |

アカウントごとの UTC 日あたりの無料アドレス数: 1000

アカウントごとの UTC 日あたりの無料アドレス枠は、プランに関係なくすべてのサブスクリプショングループで共有されます。各グループについて、その日にオンラインだった間の最大アドレス数を数え、グループ ID の昇順に枠を割り当てます。同じアドレスが二つのグループにある場合は二回数えます。グループ内のチェーン数によってアドレス数が倍増することはありません。一日中オフラインまたは削除済みのグループは数えません。各グループの割り当て枠を差し引いた残りの数に、`push.address_day` CU ウェイト（`method_weights` に記載）を掛けます。現在設定されている枠はアドレス日課金と同じ料金ポリシーに基づきます。アカウントの容量制限でも、グループごとの個別枠でもありません。

例：配信済みの native.transfer イベント 10 件、成功した履歴リクエスト 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/)で、対応するイベント、チェーンの対応範囲、料金を比較してください。
