# Webhook push subscriptions

> Original page: https://docs.blockvectra.com/en/guides/webhook-push/

A subscription combines one HTTPS receiving URL, a signing secret, watched EVM addresses and a required `chains` object. Addresses apply to every chain in that object. Use the API with an `x-api-key` header; any active key in your account can manage all of its subscriptions. [Get an API key](https://blockvectra.com/en/get-api-key/) before starting. The [Push OpenAPI](https://docs.blockvectra.com/openapi/push.yaml) lists every operation and webhook schema.

## Create a subscription

Read `GET /v1/push/chains` for available chains and their minimum, default and maximum confirmation counts. A block is released when `head - block + 1 >= confirmations`. Each chain can use its default by supplying `{}`. At least one chain is required; new chains do not automatically join existing subscriptions.

Save the following example as `create.json`, replacing the URL with your receiver and selecting chains from the chain list. The URL must use HTTPS on port 443, a hostname rather than an IP literal, and contain no user information or fragment.

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

Set `BLOCKVECTRA_API_KEY` in your environment, then run:

```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
```

A successful create returns HTTP 201 and an `online` subscription with no addresses. Store its numeric `id` and `secret` securely. The secret is returned only on creation or `POST /subscriptions/{subscription_id}/rotate-secret`; rotation takes effect immediately on all chains, with no overlap. No test message is sent.

## Add, remove and list addresses

Save an address batch as `addresses.json`, replacing the example addresses with the ones you watch:

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

Set `SUBSCRIPTION_ID` to the returned subscription 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
```

Each add or remove accepts at most 10,000 addresses. Input addresses are lowercase or valid EIP-55 mixed case; invalid input rejects the whole batch. Repeated addresses count as `unchanged`, so resending the same add or remove is safe. Lists use `limit` and `page_token`; `next_page_token: null` marks the last page.

Mutations return `change_version`. Poll `GET /subscriptions/{subscription_id}` until `applied_version >= change_version`; each chain’s `applied_from_block` identifies the effective block. New addresses are not matched retroactively. Removing an address stops new matching from that block; previously matched events can still arrive.

Use JSON Merge Patch on `PATCH /subscriptions/{subscription_id}` to change `url`, `key_id`, `status` or `chains`: a chain object adds or updates it, and `null` removes it. At least one chain must remain. `offline` stops listening and delivery while retaining configuration; going `online` starts matching at the current effective block and does not backfill the offline period. `DELETE` permanently removes the subscription.

## Event format

Every POST has `type: push.events`, `created_at` and `data`. `data` contains `subscription_id`, one `chain`, `complete_through_block` and `events`. Record progress per chain: a block can span messages, so individual event block numbers are not a completion marker.

```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"
          }
        ]
      }
    ]
  }
}
```

| Event type         | What to handle                                                                                                                                                                                |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `native.transfer`  | Successful top-level native-value transfers involving a watched address; `amount` is an integer decimal string. Internal native transfers are excluded.                                       |
| `token.transfer`   | ERC-20, ERC-721 and ERC-1155 transfers involving watched addresses; inspect `standard`, `token`, `token_id`, `amount` and `batch_index`. ERC-1155 batch transfers produce one event per item. |
| `log`              | Other logs naming a watched address as the emitting contract or in topics 1–3; inspect `address`, `topics`, `data` and `matched`.                                                             |
| `subscription.gap` | A range from `from_block` to `to_block` is unavailable for delivery, with `reason: retention_expired`; backfill with Data API or `eth_getLogs`.                                               |
| `chain.incident`   | Previously delivered blocks in `from_block`–`to_block` were replaced; reconcile by `tx_hash` and process subsequent canonical events.                                                         |

Within a subscription, deduplicate by event `id`; across subscriptions use `ref` and `type`. Ignore unknown fields and event types. Verify on-chain facts before taking financial action.

## Verify signatures

The headers are `webhook-id`, `webhook-timestamp`, `webhook-signature` and `bv-subscription-id`. Pick the secret only from subscriptions you created; reject unknown IDs. Verify HMAC-SHA256 over `webhook-id.webhook-timestamp.raw-body`, using the bytes of the original request body, before parsing JSON. The signature is `v1,<base64>`; allow about five minutes of timestamp skew and compare in constant time.

This Node.js function accepts the raw body as a `Buffer`, request headers and a map of subscription IDs to stored secrets:

```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);
}
```

After verification, parse the body, persist processing and return 2xx within 10 seconds. The subscription ID header is untrusted until the signature is verified.

## Delivery, retries and replay

Delivery is at least once. Each subscription’s chain is ordered by block and position in the block; failed batches block later events on that chain. Different chains have independent progress and can POST concurrently. A retry of an identical batch retains `webhook-id`, but a changed batch can have a new ID: deduplicate events, not batches.

Any 2xx within 10 seconds acknowledges durable processing. Redirects are not followed; 3xx and 410 are failures. After a failure, retries follow immediate, 5-second, 30-second, 2-minute, 10-minute, 30-minute and 1-hour intervals, then hourly. A 429 `Retry-After` can extend the wait up to one hour. Inspect each chain’s `condition`, `last_error` and `next_attempt_at` when delivery stops. The conditions are `receiver_failing`, `insufficient_balance` and `key_revoked`; the last requires patching `key_id` to another active account key.

Undelivered events expire outside the retention window and produce `subscription.gap`. `POST /subscriptions/{subscription_id}/replay` takes `chain` and `from_block`; consult `replayable_from_block` in `GET /push/chains` and the subscription’s progress. Replay delivers existing matches and cannot recover events from before an address or chain was added. A reorg affecting delivered blocks produces `chain.incident` followed by canonical-event redelivery; inspect `halted` in the chain list if the chain has stopped.

Query delivered data events with `GET /subscriptions/{subscription_id}/events?chain=...`, optionally adding `from_block`, `to_block`, `limit` and `page_token`. History rows contain `event`, `replay_epoch`, `orphaned` and `delivered_at`; `orphaned: true` marks a block later replaced. See [error handling](https://docs.blockvectra.com/en/errors/) for invalid ranges and retry guidance.

## Billing and example

Weights come from `GET /v1/plans`. Delivered data events, successful history requests and address-days have separate weights; management calls other than history, control events, failed deliveries and automatic retries are free. Each delivered event is charged once; customer replay and canonical-event redelivery incur new delivery charges.

Address billing uses each subscription’s largest address count during its online portion of the UTC day, after the account free-address allowance shared across subscriptions (older subscriptions first). The same address in two subscriptions counts twice; adding chains changes event charges, not address charges. A subscription offline for the entire UTC day has no address charge.

| Usage | Billing unit | CU |
| --- | --- | --- |
| `push.address_day` | Billable address-day | 33 |
| `push.history` | Successful history request | 25 |
| `push.log` | Delivered data event | 150 |
| `push.native_transfer` | Delivered data event | 150 |
| `push.token_transfer` | Delivered data event | 150 |

Free addresses per account per UTC day: 1000

Free address allowance per account per UTC day, shared by all subscription groups regardless of plan. For each group, count its maximum address count while online during that day; allocate the allowance in ascending group ID order. The same address in two groups counts twice; the number of chains in a group does not multiply its address count. A group offline or deleted for the entire day contributes nothing. For each group, the remaining count after its share of the allowance is multiplied by the `push.address_day` CU weight in `method_weights`. The current configured allowance comes from the same pricing policy used for the address-day charge; it is not an account capacity limit or a separate allowance per group.

Example: 10 delivered native.transfer events, 2 successful history requests and 10 billable address-days cost 10 × 150 + 2 × 25 + 10 × 33 = 1880 CU. Billable address-days are counted after the account free-address allowance.

See [billing rules](https://docs.blockvectra.com/en/guides/billing-rules/) and the [pricing page](https://blockvectra.com/en/pricing/) for CU metering and conversion.
