Webhook push subscriptions

Receive address activity on selected chains, manage subscriptions, verify webhook signatures and understand delivery and CU billing.

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 before starting. The Push OpenAPI 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.

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

Set BLOCKVECTRA_API_KEY in your environment, then run:

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:

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

Set SUBSCRIPTION_ID to the returned subscription ID:

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.

{
  "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 typeWhat to handle
native.transferSuccessful top-level native-value transfers involving a watched address; amount is an integer decimal string. Internal native transfers are excluded.
token.transferERC-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.
logOther logs naming a watched address as the emitting contract or in topics 1–3; inspect address, topics, data and matched.
subscription.gapA range from from_block to to_block is unavailable for delivery, with reason: retention_expired; backfill with Data API or eth_getLogs.
chain.incidentPreviously 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:

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

UsageBilling unitCU
push.address_dayBillable address-day33
push.historySuccessful history request25
push.logDelivered data event150
push.native_transferDelivered data event150
push.token_transferDelivered data event150

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 and the pricing page for CU metering and conversion.

Last updated:

On this page