TarifsGitHubTableau de bord

Public API

Webhooks

A webhook is one URL per account. RpcNode sends a signed JSON POST to it when something you subscribed to happens: a transfer on a watched address, the end of a watched transaction, a change of a payment, or a new block. You configure it with GET and PUT /v1/webhooks, check every delivery with the X-RpcNode-Signature header, and read what was sent in GET /v1/webhooks/deliveries. All routes require signed headers (see Authentication). Base URL: see Overview. Errors use { ok: false, error_code, error } (plus errors[] for an invalid request). A field without a value is not written (it is never null) unless this article shows an explicit null.

webhooksignaturedeliveryretriescreditsegress

← Public API

How it works

An account has one webhook: a URL, an enabled flag, a signing secret, and a list of networks for each kind of event it wants (payment, payout, block). Delivery is asynchronous: the POST reaches you after the event was seen, never inside the request that created the watch or payment.

The WebSocket (see WebSockets) is a separate transport of the same events with its own settings. A webhook that is switched on does not switch the WebSocket on, and the other way round. An event that both transports want is delivered twice and paid twice.

The plan decides whether webhooks are available: when webhook_address_slots of the plan is 0, saving a URL or enabling the webhook fails with 403 watch.webhook_unavailable.

Kinds of events

Every delivery has a kind. It is the type field of the JSON body and the X-RpcNode-Event header. The one exception is block: its body is the raw block and has no type field, so read the header.

payment: value arrived on a watched address. payout: value left a watched address. address: a transaction touched a watched address without moving value (approve, contract call, transfer to itself). These three are the events of Address Watch.

tx: a watched transaction hash was seen in a block (event detected), got one more confirmation (confirmations), or ended (confirmed, failed or cancelled). See Tx Watch.

payment_order: a payment of the Payments product changed state (event check, underpaid, expired, failed, cancelled, aml_check or paid). See Payments.

block: a new block on a network. See Block events.

test: the delivery made by POST /v1/webhooks/test.

Which of them reach the URL: payment and payout follow events.payment and events.payout. address and tx are delivered when either payment or payout is on for that network. block follows events.block. payment_order is delivered to any enabled webhook that has a URL, whatever events says. test is delivered even when the webhook is switched off (it only needs a saved URL).

A network list is true (every network), false (off) or an array of slugs. A bare slug such as "tron" means the mainnet of that network. A testnet is named by its full environment slug, for example "tron-nile". Slugs are trimmed, lowercased and deduplicated, and are not checked against the network catalogue.

The delivery request

RpcNode sends POST to webhook.url with a UTF-8 JSON body. Redirects are not followed, so a 3xx answer counts as a failure.

Headers: Content-Type: application/json. User-Agent: RpcNode-Webhooks/1. X-RpcNode-Delivery: the delivery id, the same value as delivery_id in the body and the same on every retry, so use it as your idempotency key. X-RpcNode-Event: the kind (payment, payout, address, tx, payment_order, block or test). X-RpcNode-Signature: the signature, described below.

Block deliveries carry two more headers because their body (the raw block) says nothing about itself: X-RpcNode-Network (the network slug, for example tron-nile) and X-RpcNode-Block-Height. All other kinds have the network inside the body.

The webhook URL must start with http:// or https://, carry no user name or password, and its host must not resolve to this machine, a private network, link-local, multicast or carrier-grade NAT address. This is checked again before every send. A delivery to a forbidden address is marked dead at once (no retries).

Allowlisting: GET /v1/webhooks returns egress_ips, the public addresses deliveries are sent from. The list is empty when none are published; do not rely on IPs alone, check the signature.

Verify the signature

The header looks like X-RpcNode-Signature: t=1787347206,v1=9f2c…. t is the Unix time in seconds when this attempt was signed. v1 is the lowercase hex of HMAC-SHA256 with the webhook secret as the key (the secret string as UTF-8 text, not decoded from hex) over the string "{t}.{raw body}": the timestamp, a dot, then the body bytes exactly as received.

Compute it over the raw bytes of the body, before any JSON parsing or re-serialisation. Compare in constant time and reject a t that is too far from your clock (5 minutes is a sensible window). A retry is signed again with a fresh t, so the window applies to every attempt, not to the first one.

The secret is webhook.secret from GET /v1/webhooks (also returned by PUT and by the rotate route). It is created on the first save. The header is present on every delivery of a webhook that has a secret, which is every saved webhook.

The Node.js example below verifies the signature, answers fast and handles the deliveries by kind. For a block delivery the body is the block, so branch on the header before you parse it.

Node.js (Express): verify and handle

import crypto from 'node:crypto'
import express from 'express'

const SECRET = process.env.RPCNODE_WEBHOOK_SECRET // webhook.secret
const TOLERANCE_SECONDS = 300

function verify(rawBody, header, secret) {
  if (!header) return false
  const parts = {}
  for (const item of header.split(',')) {
    const i = item.indexOf('=')
    if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim()
  }
  const t = Number(parts.t)
  if (!Number.isInteger(t) || !parts.v1) return false
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.`)
    .update(rawBody) // Buffer: the bytes as received
    .digest()
  const given = Buffer.from(parts.v1, 'hex')
  return given.length === expected.length && crypto.timingSafeEqual(given, expected)
}

const app = express()
const seen = new Set() // use a store with expiry in production

// Blocks can be large: raise the limit if you subscribe to block events.
app.post('/rpcnode/webhook', express.raw({ type: '*/*', limit: '64mb' }), (req, res) => {
  if (!verify(req.body, req.get('X-RpcNode-Signature'), SECRET)) {
    return res.status(401).end()
  }

  const deliveryId = req.get('X-RpcNode-Delivery')
  const kind = req.get('X-RpcNode-Event')
  res.status(200).end() // answer first, work afterwards: you have 10 seconds

  if (seen.has(deliveryId)) return // a retry of something already handled
  seen.add(deliveryId)

  if (kind === 'block') {
    const height = Number(req.get('X-RpcNode-Block-Height'))
    const network = req.get('X-RpcNode-Network')
    // req.body is the raw block as the node returned it
    return handleBlock(network, height, req.body)
  }

  const event = JSON.parse(req.body.toString('utf8'))
  switch (event.type) {
    case 'payment': // event.event: detected | pending | confirmations | confirmed | failed
    case 'payout':
    case 'address':
      return handleAddressEvent(event)
    case 'tx': // event.event: detected | confirmations | confirmed | failed | cancelled
      return handleTxEvent(event)
    case 'payment_order':
      return handlePaymentOrder(event)
    case 'test':
      return console.log('test delivery', event.delivery_id)
  }
})

// Replace logging with your application processing.
function handleAddressEvent(event) { console.log('address event', event) }
function handleTxEvent(event) { console.log('transaction event', event) }
function handlePaymentOrder(event) { console.log('payment order', event) }
function handleBlock(network, height, body) { console.log('block', network, height, body.length) }

app.listen(3000)

Retries and delivery rules

Any 2xx answer is a delivery. Everything else is a failure: another status (including 3xx, 4xx and 5xx), a connection error, or no answer in time. RpcNode waits up to 5 seconds to connect and 10 seconds for the answer; the answer body is ignored.

A failed delivery is retried after 10 s, 30 s, 2 min, 10 min, 1 h and 6 h (each pause varies by about ±10%). That is 7 attempts in total, roughly 7 hours. After the last failure the delivery is dead and is not sent again. Every attempt reads the webhook again, so a changed URL or secret applies to deliveries that are still waiting.

Before each attempt the webhook is checked again. If it was removed or disabled, or it no longer wants this kind of event or network, the delivery is cancelled and nothing is sent.

Concurrency is limited: at most 4 deliveries to your webhook are in flight at once, across all networks. The rest wait. A slow server therefore delays only your own deliveries. Order is not guaranteed (parallel sends and retries), so use block_height, block_time and confirmations instead of arrival order.

A delivery is idempotent: the same event always has the same delivery_id (X-RpcNode-Delivery), also across retries. Because RpcNode may deliver the same event more than once, deduplicate by it.

Body of every event (the common header)

Except for block, every body is a flat JSON object that starts with the same five fields in this order: schema_version, delivery_id, type, event and network. schema_version is the string "2". delivery_id is the idempotency key. type is the kind. event says what happened within the kind and is never empty. network is the network slug (tron, tron-nile, ...), or null when the event has no network (test).

After the header come the fields of the kind. What is not known is not sent, except for the explicit nulls shown below: there are no empty placeholder strings.

Payment, payout and address events

payment: value arrived on a watched address. payout: value left it. address: any other transaction that touches it. direction is relative to address: in, out or self. The numbers are strings.

Fields: tx_hash; block_height and block_hash (null while the transaction is only in the mempool, which Bitcoin, Litecoin and Dash report as event pending); block_time (Unix seconds); address (the watched address); watch_id (the id from POST /v1/address-watches); payment_id (only when the event is about a Payments payment); from_address, to_address, direction; status; method (the contract method such as transfer, empty for a native transfer); token; currency and amount; amount_raw; fee and fee_raw; confirmations; required_confirmations; confirmed.

token is { contract, standard, symbol, decimals } and is absent for the native coin. symbol and decimals are absent for a token that is not in the network token catalogue; then currency and amount are absent too and only amount_raw is sent. amount is the human amount ("30"), amount_raw is the amount in the smallest unit. fee and fee_raw (network fee in the native coin and in smallest units) are absent when the node does not report them.

An address event is followed up like a payment. event is detected when the transaction is first seen in a block (status check, confirmed false), then confirmations for each further confirmation (confirmations 2, 3, ... up to required_confirmations - 1), and finally confirmed (status success) or failed (status failed), both with confirmed true and confirmations equal to required_confirmations. A pending event (mempool) comes before detected. required_confirmations is the number your account has for that network (the confirmation settings in the cabinet), or the default of the network.

Code that told the first event by the missing event field must now look for detected.

Payment (incoming USDT on Tron, first event)

{
  "schema_version": "2",
  "delivery_id": "3f9c1e0b7a2d4c58b1a6e9d07f42c5a18e6b3d90",
  "type": "payment",
  "event": "detected",
  "network": "tron",
  "tx_hash": "bb08e86bca3219613b2d10f8b731c139aebc74c7a85f2a736b18bed50a2c320c",
  "block_height": 85558636,
  "block_hash": "000000000519856c255ef4a490eae5ee6ff6e210bd572edcb9d49e7dafef8500",
  "block_time": 1787347206,
  "address": "TToLVWCw5pxV241Rdt3StsUZRTb15QRzRG",
  "watch_id": "c9fd242d-525b-4b81-9a99-b2a95be331c9",
  "from_address": "TU4vEruvZwLLkSfV9bNw12EJTPvNr7Pvaa",
  "to_address": "TToLVWCw5pxV241Rdt3StsUZRTb15QRzRG",
  "direction": "in",
  "status": "check",
  "method": "transfer",
  "token": {
    "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
    "standard": "trc20",
    "symbol": "USDT",
    "decimals": 6
  },
  "currency": "USDT",
  "amount": "30",
  "amount_raw": "30000000",
  "confirmations": 1,
  "required_confirmations": 19,
  "confirmed": false
}

// The last event of the same transaction (same shape, other values):
// "event": "confirmed", "status": "success", "confirmations": 19, "confirmed": true

Event tx

A watched transaction hash going on and ending (see Tx Watch). event is detected (seen in a block, the first confirmation), confirmations (one more; the confirmations field says which), confirmed or failed (the end), or cancelled. The body has the header, tx_hash and watch_id. Every event but cancelled also has the transaction data from the block: block_height, block_hash, block_time, from_address, to_address, status, method, token, currency, amount, amount_raw, fee, fee_raw, confirmations, required_confirmations and confirmed (true only on confirmed and failed). There are no address and direction because the watch is not about an address.

For cancelled (no node ever knew the hash) there is no block: only the header, tx_hash and watch_id.

Event tx, cancelled

{
  "schema_version": "2",
  "delivery_id": "a41d0c7e5b9f4a2e8c3d6f1b0e7a9c24d5f8b631",
  "type": "tx",
  "event": "cancelled",
  "network": "tron",
  "tx_hash": "bb08e86bca3219613b2d10f8b731c139aebc74c7a85f2a736b18bed50a2c320c",
  "watch_id": "7d3a5e18-0c4b-4f6a-9b21-5e8c7a1d3f90"
}

Event payment_order

A payment of the Payments product changed state. The body always shows the current state of the payment. The list of its fields, the lifecycle, and the AML object are on the Payments page.

payment_order goes to the account webhook (there is no per-payment URL) and ignores the events lists: you receive it whenever the webhook is enabled.

eventWhen it is sent
checkThe money was first seen on-chain. This is the only payment event that is billed.
confirmationsThe number of confirmations changed. Free.
aml_checkThe required confirmations were reached; the risk screening is running.
paidThe payment succeeded (with the aml object filled when the network has AML).
underpaidLess than requested was received; adds underpay_tx_hash and underpay_block.
expiredThe payment expired.
failedThe transfer failed on the chain.
cancelledYou cancelled the payment.

Event payment_order, check

{
  "schema_version": "2",
  "delivery_id": "d0e5a1f37b4c4e9a8f2b6c1d9e3a7f50b2c84d16",
  "product": "payments",
  "type": "payment_order",
  "event": "check",
  "payment_id": "5b7d9a14-3c2e-4f80-a1b6-9e4d2c7f0a35",
  "order_id": "ord_123",
  "network": "tron",
  "address": "TToLVWCw5pxV241Rdt3StsUZRTb15QRzRG",
  "amount": "30",
  "amount_raw": "30000000",
  "decimals": 6,
  "token": { "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "symbol": "USDT", "decimals": 6 },
  "status": "check",
  "tx_hash": "bb08e86bca3219613b2d10f8b731c139aebc74c7a85f2a736b18bed50a2c320c",
  "min_block_height": 85558600,
  "block_height": 85558636,
  "confirmations": 1,
  "required_confirmations": 19,
  "final_block": null,
  "received_amount_raw": "30000000",
  "network_fee": null,
  "network_fee_raw": null,
  "network_fee_symbol": null,
  "fail_reason": null,
  "aml": null,
  "expires_at": "2026-08-16T10:30:00+00:00",
  "created_at": "2026-08-16T10:00:00+00:00",
  "updated_at": "2026-08-16T10:01:12+00:00",
  "lifecycle": "check"
}

Event test

The delivery made by POST /v1/webhooks/test. It is a fixed sample, is signed like any other delivery, and costs no credits. network is an explicit null.

Event test

{
  "schema_version": "2",
  "delivery_id": "test-5e1b0c9a7d3f4a82b6c4e8d1f0a93b27",
  "type": "test",
  "event": "test",
  "network": null,
  "message": "This is a test delivery from RpcNode. If you can read it, your webhook URL works.",
  "sent_at": 1787347206
}

Event block

The block webhook is different from all other kinds. The POST body is the block exactly as the node returned it, with no wrapper and no schema_version, and it can be large (hundreds of KB to tens of MB, depending on the network). The network and height are in the X-RpcNode-Network and X-RpcNode-Block-Height headers, and the signature is computed over this body like any other.

The WebSocket gets a short descriptor of the block instead of the block itself. Both forms, how to subscribe, the price and how to fetch the block are in Block events.

Credits

A delivery is paid when it is created, not when your server answers: whatever your server does, the charge is the same. A retry is not paid again, and the same event is never charged twice. The charge is a usage row named after its bucket (see GET /v1/usage).

webhook:address-watch: payment, payout and address events. Only the first event of a transaction (detected) is charged; pending, confirmations, confirmed and failed are free. webhook:payment: a Payments payment is charged once, on its first event (check); its other events are free. webhook:tx-watch: the end of a watched hash (tx). webhook:block: every block delivery. test deliveries are free, and so is any bucket whose price is 0.

Prices are per network: GET /v1/networks/{slug}/credits returns address_watch_credits, payment_credits, tx_watch_credits and block_credits for each environment. The block price is the price of the full get-block call of that network. The WebSocket pays the same prices, once per event and subscription.

Delivery log

GET /v1/webhooks/deliveries lists the recent deliveries to your URL, newest first.

status is pending (waiting for the first or the next attempt), sent (answered 2xx), dead (all attempts failed, or the URL is forbidden) or cancelled (the webhook was removed, disabled or no longer wants it). For a pending delivery next_attempt_at says when the next attempt is due. http_status and error describe the last attempt; error is a short text such as the server answered 503.

GET /v1/webhooks

The webhook of the account: URL, enabled flag, networks per kind of event, egress IPs and the signing secret.

Champs de la réponse

NomTypeDescription
okbooleantrue on success.
webhook.urlstringWebhook URL. Absent when none is saved. (facultatif)
webhook.enabledbooleanWhether events are delivered to the URL.
webhook.events.paymentstring[]Networks for incoming transfers (event kind payment). Empty when off or when payment_all is true.
webhook.events.payoutstring[]Networks for outgoing transfers (event kind payout). Empty when off or when payout_all is true.
webhook.events.blockstring[]Networks for new blocks (event kind block). Empty when off or when block_all is true.
webhook.events.payment_allbooleantrue when payment is on for every network.
webhook.events.payout_allbooleantrue when payout is on for every network.
webhook.events.block_allbooleantrue when block is on for every network.
webhook.egress_ipsstring[]Public addresses deliveries are sent from. May be empty.
webhook.secretstringSigning secret (64 hex characters), created on the first save. Absent until then. (facultatif)
webhook.has_address_activebooleantrue when the account has at least one active watched address. (facultatif)
  • Takes no query parameters or body. The webhook belongs to the account (the owner of the API key).
  • When no webhook was ever saved, the response still succeeds: enabled is false, every events list is empty with *_all false, url and secret are absent, and egress_ips and has_address_active are present.
  • events lists differ from the request form: payment, payout and block are arrays of network slugs, and payment_all, payout_all and block_all tell that the whole kind is on for every network (the array is then empty). An empty array with *_all false means the kind is off.
  • secret is returned on every read, not only once. Keep it on your server and treat it like an API key secret.

Exemple de requête

import { createHash, createHmac } from 'node:crypto'

const API_KEY = process.env.PUBLIC_API_KEY! // cabinet UUID
const SECRET = process.env.PUBLIC_API_SECRET! // sk_live_…
const BASE = 'https://api.rpcnode.dev'

// path: as the server receives it, with the query; body: the exact string that is sent
async function call(method: string, path: string, body = '') {
  const timestamp = Math.floor(Date.now() / 1000)
  const bodyHash = createHash('sha256').update(body).digest('hex')
  const signature = createHmac('sha256', SECRET)
    .update([timestamp, method, path, bodyHash].join('\n'))
    .digest('hex')
  const res = await fetch(BASE + path, {
    method,
    headers: {
      'X-Api-Key': API_KEY,
      'X-Timestamp': String(timestamp),
      'X-Signature': signature,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body || undefined,
  })
  return res.json()
}

console.log(await call('GET', '/v1/webhooks'))

La requête est signée dans l’extrait lui-même — voir Authentification pour les règles de signature.

Exemple de réponse

{
  "ok": true,
  "webhook": {
    "url": "https://example.com/hooks/rpcnode",
    "enabled": true,
    "events": {
      "payment": [],
      "payout": ["tron", "ethereum"],
      "block": ["tron-nile"],
      "payment_all": true,
      "payout_all": false,
      "block_all": false
    },
    "egress_ips": ["203.0.113.10", "203.0.113.11"],
    "secret": "6f1c0d7e2b9a4c35e8d1a0b7f4c92e63a5d8b10c7e4f29a6d3b8c1e05f7a9d24",
    "has_address_active": true
  }
}

PUT /v1/webhooks

Save the URL, switch the webhook on or off, and choose the networks per kind of event.

Corps de la requête

NomTypeDescription
urlstringWebhook URL (http:// or https://). Omit or send "" to clear it. (facultatif)
enabledbooleantrue to deliver events to the URL.
eventsobjectOptional. Keys payment, payout and block only; each true, false or an array of network slugs. Replaces all three lists when present; a missing key means off. (facultatif)

Champs de la réponse

NomTypeDescription
okbooleantrue on success.
webhookobjectThe saved webhook, in the form of GET /v1/webhooks (without has_address_active).
webhook.secretstringSigning secret, present after the first save.
  • enabled is required. url is not kept when omitted: if you leave it out or send an empty string, the saved URL is cleared. Unknown fields are rejected with 400 and named.
  • url must start with http:// or https://. enabled: true with no valid URL fails with 400 watch.webhook_url_required; a URL that does not start with http:// or https:// fails with 400 watch.webhook_url_invalid. The secret is created on the first save and stays the same afterwards (rotate it with POST /v1/webhooks/secret).
  • events is optional. Omit it to keep the saved lists. When you send it, it replaces all three lists: a missing key means off. Only payment, payout and block are accepted. Each is true (every network), false (off) or an array of network slugs. Any other key, or any other value, gives 400 common.validation_failed with errors[] naming events.<key>.
  • Enabled with no events ever saved and events omitted: payment and payout are turned on for every network. block stays off, because every new block would be delivered to your URL.
  • If you send events with everything off while enabled is true, nothing is left to deliver, so the webhook is saved with enabled false. Check enabled in the response.
  • A bare slug (tron) selects the mainnet, a full environment slug (tron-nile) selects that environment. true on block includes the testnets of every network that supports block events. Slugs are lowercased and deduplicated but not checked against the catalogue: a misspelt slug is saved and simply never matches.
  • Errors: 403 watch.webhook_unavailable (the plan has no webhook slots), 400 watch.no_plan, 400 watch.webhook_url_required, 400 watch.webhook_url_invalid, 400 common.validation_failed. Saving enabled false with no URL is always allowed.

Exemple de requête

import { createHash, createHmac } from 'node:crypto'

const API_KEY = process.env.PUBLIC_API_KEY! // cabinet UUID
const SECRET = process.env.PUBLIC_API_SECRET! // sk_live_…
const BASE = 'https://api.rpcnode.dev'

// path: as the server receives it, with the query; body: the exact string that is sent
async function call(method: string, path: string, body = '') {
  const timestamp = Math.floor(Date.now() / 1000)
  const bodyHash = createHash('sha256').update(body).digest('hex')
  const signature = createHmac('sha256', SECRET)
    .update([timestamp, method, path, bodyHash].join('\n'))
    .digest('hex')
  const res = await fetch(BASE + path, {
    method,
    headers: {
      'X-Api-Key': API_KEY,
      'X-Timestamp': String(timestamp),
      'X-Signature': signature,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body || undefined,
  })
  return res.json()
}

console.log(await call('PUT', '/v1/webhooks', '{"enabled":true}'))

La requête est signée dans l’extrait lui-même — voir Authentification pour les règles de signature.

Exemple de réponse

// PUT /v1/webhooks
{
  "url": "https://example.com/hooks/rpcnode",
  "enabled": true,
  "events": { "payment": true, "payout": ["tron"], "block": ["tron-nile"] }
}

// 200
{
  "ok": true,
  "webhook": {
    "url": "https://example.com/hooks/rpcnode",
    "enabled": true,
    "events": {
      "payment": [],
      "payout": ["tron"],
      "block": ["tron-nile"],
      "payment_all": true,
      "payout_all": false,
      "block_all": false
    },
    "egress_ips": ["203.0.113.10"],
    "secret": "6f1c0d7e2b9a4c35e8d1a0b7f4c92e63a5d8b10c7e4f29a6d3b8c1e05f7a9d24"
  }
}

// 400 (an unknown event key)
{
  "ok": false,
  "error_code": "common.validation_failed",
  "error": "token is not an event. Use payment, payout or block.",
  "errors": [
    {
      "field": "events.token",
      "error_code": "common.validation_failed",
      "error": "token is not an event. Use payment, payout or block."
    }
  ]
}

POST /v1/webhooks/secret

Issue a new signing secret. The old one stops working at once.

Champs de la réponse

NomTypeDescription
okbooleantrue on success.
webhookobjectThe webhook in the form of GET /v1/webhooks (without has_address_active).
webhook.secretstringThe new signing secret (64 hex characters).
  • Takes no body. The webhook must have been saved at least once; otherwise the answer is 400 watch.webhook_url_required.
  • Deliveries are signed with the new secret from the moment this route answers, including retries of deliveries that are still waiting. Put the new secret on your server first or accept both for a short while, then rotate.
  • The new secret is in the response and, like the old one, can be read again with GET /v1/webhooks.

Exemple de requête

import { createHash, createHmac } from 'node:crypto'

const API_KEY = process.env.PUBLIC_API_KEY! // cabinet UUID
const SECRET = process.env.PUBLIC_API_SECRET! // sk_live_…
const BASE = 'https://api.rpcnode.dev'

// path: as the server receives it, with the query; body: the exact string that is sent
async function call(method: string, path: string, body = '') {
  const timestamp = Math.floor(Date.now() / 1000)
  const bodyHash = createHash('sha256').update(body).digest('hex')
  const signature = createHmac('sha256', SECRET)
    .update([timestamp, method, path, bodyHash].join('\n'))
    .digest('hex')
  const res = await fetch(BASE + path, {
    method,
    headers: {
      'X-Api-Key': API_KEY,
      'X-Timestamp': String(timestamp),
      'X-Signature': signature,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body || undefined,
  })
  return res.json()
}

console.log(await call('POST', '/v1/webhooks/secret'))

La requête est signée dans l’extrait lui-même — voir Authentification pour les règles de signature.

Exemple de réponse

{
  "ok": true,
  "webhook": {
    "url": "https://example.com/hooks/rpcnode",
    "enabled": true,
    "events": {
      "payment": [],
      "payout": [],
      "block": [],
      "payment_all": true,
      "payout_all": true,
      "block_all": false
    },
    "egress_ips": ["203.0.113.10"],
    "secret": "b2e84d1a9c07f35e6a1d8c4b0f927e53d6a1c8f40b9e27d5a3c6f18e0d4b7a92"
  }
}

POST /v1/webhooks/test

Send a test delivery to the saved URL. How it went shows in the delivery log.

Champs de la réponse

NomTypeDescription
okbooleantrue on success.
deliveryobjectThe delivery, in the form of one row of the delivery log.
delivery.delivery_idstringDelivery id, starts with test-.
delivery.kindstringAlways test.
delivery.statusstringpending right after the request.
  • Takes no body. The webhook may be switched off and events are ignored: a saved URL is enough. A test is signed like any other delivery, and is not charged.
  • The delivery is sent within about a second. The response is the delivery row (status pending); read GET /v1/webhooks/deliveries to see whether your server answered 2xx.
  • Errors: 400 watch.webhook_url_required (no URL saved), 403 watch.webhook_unavailable (the plan has no webhook slots), 400 watch.no_plan, 429 watch.webhook_test_too_often (another test was queued less than 5 seconds ago).

Exemple de requête

import { createHash, createHmac } from 'node:crypto'

const API_KEY = process.env.PUBLIC_API_KEY! // cabinet UUID
const SECRET = process.env.PUBLIC_API_SECRET! // sk_live_…
const BASE = 'https://api.rpcnode.dev'

// path: as the server receives it, with the query; body: the exact string that is sent
async function call(method: string, path: string, body = '') {
  const timestamp = Math.floor(Date.now() / 1000)
  const bodyHash = createHash('sha256').update(body).digest('hex')
  const signature = createHmac('sha256', SECRET)
    .update([timestamp, method, path, bodyHash].join('\n'))
    .digest('hex')
  const res = await fetch(BASE + path, {
    method,
    headers: {
      'X-Api-Key': API_KEY,
      'X-Timestamp': String(timestamp),
      'X-Signature': signature,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body || undefined,
  })
  return res.json()
}

console.log(await call('POST', '/v1/webhooks/test'))

La requête est signée dans l’extrait lui-même — voir Authentification pour les règles de signature.

Exemple de réponse

{
  "ok": true,
  "delivery": {
    "delivery_id": "test-5e1b0c9a7d3f4a82b6c4e8d1f0a93b27",
    "kind": "test",
    "network": "",
    "block_height": 0,
    "tx_hash": "",
    "address": "",
    "event": "",
    "status": "pending",
    "attempts": 0,
    "created_at": "2026-08-16T10:00:00",
    "next_attempt_at": "2026-08-16T10:00:00"
  }
}

GET /v1/webhooks/deliveries

The delivery log: recent deliveries to your URL with their status, attempts and last answer.

Paramètres de requête

NomTypeDescription
statusstringOnly deliveries in this status: pending, sent, dead or cancelled. (facultatif)
pageintegerPage number, from 1 (default 1). (facultatif)
per_pageintegerPage size, 1 to 100 (default 20). (facultatif)

Champs de la réponse

NomTypeDescription
okbooleantrue on success.
deliveries[].delivery_idstringDelivery id (X-RpcNode-Delivery). Test deliveries start with test-.
deliveries[].kindstringpayment, payout, block, address, tx, payment_order or test.
deliveries[].networkstringNetwork slug; "" for a test.
deliveries[].block_heightintegerBlock height the delivery is about; 0 when there is none.
deliveries[].tx_hashstringTransaction hash; "" when there is none (block, test).
deliveries[].addressstringWatched address; "" when there is none.
deliveries[].eventstringThe event of the kind where it has one (for example confirmed, failed, check, paid); "" otherwise.
deliveries[].statusstringpending, sent, dead or cancelled.
deliveries[].attemptsintegerAttempts made so far.
deliveries[].http_statusintegerHTTP status of the last attempt. Absent when there was no answer. (facultatif)
deliveries[].errorstringWhy the last attempt failed. Absent when it did not. (facultatif)
deliveries[].created_atstringWhen the delivery was recorded.
deliveries[].sent_atstringWhen it was delivered (status sent). (facultatif)
deliveries[].next_attempt_atstringWhen the next attempt is due. Written only while the status is pending. (facultatif)
pagination.pageintegerCurrent page.
pagination.per_pageintegerPage size.
pagination.totalintegerDeliveries matching the filter.
pagination.has_morebooleantrue when another page follows.
  • Newest first. A user who never saved a webhook gets an empty list. An unknown status value matches nothing and gives an empty list, not an error.
  • per_page defaults to 20 (maximum 100); page is 1 to 10000. A value out of range is named in a 400 with errors[], not replaced by a default.
  • For a block webhook this is the place that shows whether a block was delivered.
  • Times are ISO-8601 date-times in UTC written without an offset; seconds are dropped when zero, and a fraction is written when present.

Exemple de requête

import { createHash, createHmac } from 'node:crypto'

const API_KEY = process.env.PUBLIC_API_KEY! // cabinet UUID
const SECRET = process.env.PUBLIC_API_SECRET! // sk_live_…
const BASE = 'https://api.rpcnode.dev'

// path: as the server receives it, with the query; body: the exact string that is sent
async function call(method: string, path: string, body = '') {
  const timestamp = Math.floor(Date.now() / 1000)
  const bodyHash = createHash('sha256').update(body).digest('hex')
  const signature = createHmac('sha256', SECRET)
    .update([timestamp, method, path, bodyHash].join('\n'))
    .digest('hex')
  const res = await fetch(BASE + path, {
    method,
    headers: {
      'X-Api-Key': API_KEY,
      'X-Timestamp': String(timestamp),
      'X-Signature': signature,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body || undefined,
  })
  return res.json()
}

console.log(await call('GET', '/v1/webhooks/deliveries'))

La requête est signée dans l’extrait lui-même — voir Authentification pour les règles de signature.

Exemple de réponse

{
  "ok": true,
  "deliveries": [
    {
      "delivery_id": "3f9c1e0b7a2d4c58b1a6e9d07f42c5a18e6b3d90",
      "kind": "payment",
      "network": "tron",
      "block_height": 85558636,
      "tx_hash": "bb08e86bca3219613b2d10f8b731c139aebc74c7a85f2a736b18bed50a2c320c",
      "address": "TToLVWCw5pxV241Rdt3StsUZRTb15QRzRG",
      "event": "detected",
      "status": "pending",
      "attempts": 1,
      "http_status": 503,
      "error": "the server answered 503",
      "created_at": "2026-08-16T10:00:05",
      "next_attempt_at": "2026-08-16T10:00:35"
    },
    {
      "delivery_id": "9a0b4c2e7d1f4a63b8e5c0d2f6a19b37c4e8d051",
      "kind": "block",
      "network": "tron",
      "block_height": 85558635,
      "tx_hash": "",
      "address": "",
      "event": "",
      "status": "sent",
      "attempts": 1,
      "http_status": 200,
      "created_at": "2026-08-16T10:00:03",
      "sent_at": "2026-08-16T10:00:04"
    }
  ],
  "pagination": { "page": 1, "per_page": 20, "total": 2, "has_more": false }
}

Conseils

  • Verify X-RpcNode-Signature on the raw body before you parse anything, and answer 2xx quickly. Do the work after you answered: you have 10 seconds.
  • Deduplicate by X-RpcNode-Delivery (delivery_id). The same event can arrive more than once.
  • A non-2xx answer is retried (7 attempts over about 7 hours), including 4xx. Answer 2xx for events you want to ignore.
  • An outgoing USDT transfer is payout, not payment. Turn payout on for that network, not only payment.
  • Use POST /v1/webhooks/test after every change of URL or secret, then check GET /v1/webhooks/deliveries.
  • Keep block off unless you need it: every block of a network is a delivery, and it is charged.
  • The secret can be read with GET /v1/webhooks at any time. Rotate it with POST /v1/webhooks/secret if it leaks.