Gareth Wilson Gareth Wilson

Guide to Quicknode Webhooks: Features and Best Practices

Published


Quicknode is blockchain infrastructure: RPC nodes, data APIs and two products that push on-chain data to your own servers. Quicknode Webhooks sends filtered events such as wallet transfers or contract events to a URL as they land on-chain. Quicknode Streams is a fuller pipeline that delivers blocks, transactions, receipts or logs, historical or real time, and one of its destinations is a webhook. Both sign each request the same way, so one receiver can handle either.

This guide covers how Quicknode signs its deliveries, what the payloads look like, how Streams and Webhooks retry, and the settings that decide whether your endpoint can verify what it receives.

What are Quicknode webhooks?

A Quicknode webhook is an HTTP POST with a JSON body, sent to a URL you choose. What triggers it depends on the product:

  • Quicknode Webhooks watches a chain with a filter you pick in a wizard, usually a no-code template such as "Wallet transfers" or "Contract events", and sends a payload when the filter matches.
  • Quicknode Streams runs a dataset (blocks, transactions, logs and so on) through an optional filter function and delivers each block or batch to its destinations. A webhook destination is one of several, alongside S3-compatible storage, Azure Blob Storage, PostgreSQL and Kafka.

Every delivery carries three headers: X-QN-Nonce, a unique string; X-QN-Timestamp, the time the message was signed; and X-QN-Signature, a lowercase hex HMAC-SHA256 of the nonce, the timestamp and the body joined with nothing in between. The key is the stream's or webhook's security token.

Quicknode webhook features

FeatureDetails
ProductsQuicknode Webhooks and the Quicknode Streams webhook destination
ConfigurationDashboard: Webhooks > Create Webhook, or a stream's destination settings
MethodPOST, Content-Type: application/json
Signature headerX-QN-Signature, lowercase hex, no prefix
Other signed headersX-QN-Nonce and X-QN-Timestamp
SchemeHMAC-SHA256, hex digest
Signed content{X-QN-Nonce}{X-QN-Timestamp}{raw body}, no separators
SecretThe stream's or webhook's security token, used as UTF-8 text
Event typesNone. The body is the output of your filter or dataset
CompressionOptional gzip. Keep it off if Hookdeck or any proxy verifies the body
Streams timeoutConfigurable, default 30 seconds
Streams retriesEvery N seconds (default 1), then the stream pauses after N failed retries (default 3)
Webhooks retriesAutomatic retries for non-200 responses; schedule not published
Other securityIP 141.148.40.227, mTLS client certificates and custom headers on Streams

Quicknode doesn't document the unit of X-QN-Timestamp. The example in its verification guide shows 1735613672, which looks like Unix seconds, but nothing states it.

Common events

Quicknode has no event types. No header or body field names an event, because each delivery is the output of a filter you defined. What arrives depends on the product and configuration:

SourceWhat a delivery contains
Webhooks, "Wallet transfers" templateMatched ETH, ERC-20 and ERC-721 transfers for the wallet addresses you entered
Webhooks, "Contract events" templateMatched events emitted by the contracts you entered
Streams, Blocks datasetEach block, or a batch of blocks, as returned by the chain
Streams with a filter functionWhatever your JavaScript filter returns for each block or batch

The payload format is the same as the test output you see in the dashboard when you click Run against a test block number, so that is the place to get the real shape for your own stream or webhook. A Streams batch is typically an object with data and metadata, and a Webhooks delivery is often an array of matched items, but treat both as illustrative until you have looked at your own output.

Because nothing in the request names the event, route on the URL instead. Give each stream or webhook its own path, such as /webhooks/quicknode/transfers and /webhooks/quicknode/blocks, and you always know which filter produced a payload.

Setting up Quicknode webhooks

For Quicknode Webhooks:

  1. In the Quicknode dashboard, open Webhooks and click Create Webhook.
  2. Select the chain and network, then a template or your own filter, and fill in its parameters.
  3. Test the filter: enter a block number, click Run and check the example payload.
  4. Enter your endpoint URL, for example https://example.com/webhooks/quicknode.
  5. Leave Compression off.
  6. Copy the webhook's validation token from the Webhooks dashboard and store it server-side, for example as QUICKNODE_WEBHOOK_SECRET.
  7. Name the webhook and create it.

For a Streams webhook destination:

  1. Create a stream (chain, network, dataset and optional filter), or open an existing one.
  2. Add a webhook destination and enter your URL.
  3. Set Payload compression to None.
  4. Review Timeout, Retry data fetch every and Pause stream after.
  5. Copy the Security token from the stream's Settings tab. Rotate issues a new one.
  6. Click Check Connection before you activate the stream.

The token is used as text. Don't hex-decode or base64-decode it.

If you use Hookdeck, create a source with the Quicknode source type, paste the security token into it, and use the Hookdeck source URL as the destination URL in Quicknode. Hookdeck's setup notes for this source say: "Keep payload compression (gzip) off on the stream or webhook: Quicknode signs the uncompressed body." The source accepts POST only, which is the method Quicknode uses.

Securing Quicknode webhooks

To verify a delivery, read the three X-QN-* headers. Join the nonce, the timestamp and the raw request body with no separator, compute an HMAC-SHA256 with the security token as the key, encode it as hex and compare it with X-QN-Signature in constant time. The header holds a single bare digest, so there is nothing to strip or split.

The code below has no timestamp check. Quicknode suggests "timestamp validation to prevent replay attacks", but because it doesn't say what unit the timestamp is in, any window you pick rests on a guess. If you add one, keep it optional and generous, for the reasons covered under limitations.

const crypto = require('crypto');
const express = require('express');

function verifyQuicknodeSignature(rawBody, nonce, timestamp, signature, secret) {
  if (!nonce || !timestamp || !signature || !secret) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false;

  // nonce + timestamp + body, with no separators.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(nonce, 'latin1')
    .update(timestamp, 'latin1')
    .update(rawBody)
    .digest('hex');

  const received = Buffer.from(signature.toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/quicknode', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyQuicknodeSignature(
    req.body,
    req.get('x-qn-nonce'),
    req.get('x-qn-timestamp'),
    req.get('x-qn-signature'),
    process.env.QUICKNODE_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(401);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Queue the payload, then acknowledge so the stream can move on.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import re

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")


def verify_quicknode_signature(raw_body: bytes, nonce, timestamp, signature,
                               secret: str) -> bool:
    if not nonce or not timestamp or not signature or not secret:
        return False
    if not HEX_64.fullmatch(signature):
        return False

    signed = nonce.encode("latin-1") + timestamp.encode("latin-1") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))


# In FastAPI: raw_body = await request.body()
# verify_quicknode_signature(raw_body, request.headers.get("x-qn-nonce"),
#                            request.headers.get("x-qn-timestamp"),
#                            request.headers.get("x-qn-signature"),
#                            os.environ["QUICKNODE_WEBHOOK_SECRET"])

Express's raw parser inflates a gzip body before your code sees it, so the Node sample would still pass with compression on. FastAPI and most other frameworks hand you the bytes as sent, so with compression on you would have to decompress them first. Keeping compression off avoids the difference.

Hookdeck's Quicknode source type checks the same signature: HMAC-SHA256 over {X-QN-Nonce}{X-QN-Timestamp}{body}, hex, keyed with the token. It applies no timestamp tolerance. A request that fails the check is marked as failed verification and is not delivered to your destinations.

Make Quicknode webhooks production-ready. Hookdeck Event Gateway verifies the X-QN-Signature header on every delivery, stores each block or matched event, and delivers it to your service with retries you control.

Quicknode webhook limitations and pain points

Gzip and the signed body

The Problem: Verification passes in local testing but fails once compression is switched on, or fails for every request that goes through a proxy.

Why It Happens: Quicknode computes the HMAC over the uncompressed JSON. With compression on, the bytes on the wire are gzip. Any verifier that checks the raw request body sees different bytes from the ones that were signed. Express hides this by inflating the body first, which is why the problem tends to appear only after a framework or proxy change.

Workarounds:

  • Keep payload compression off on every stream and webhook you verify.
  • If you must use compression and receive directly, decompress before verifying, and don't decompress twice in frameworks that already do it.

How Hookdeck Can Help: Hookdeck's Quicknode source verifies the body it receives, so it needs compression off too. With compression off, Hookdeck checks every delivery in one place instead of in each service.

A stream pauses after seconds of downtime

The Problem: A short deploy or a slow database query leaves a stream paused, and data stops arriving until someone notices.

Why It Happens: By default a Streams webhook destination retries every second and pauses the stream after three failed retries. Blocks are processed in order, and Streams "will not proceed to the next block or batch until receiving confirmation that the current block or batch was successfully delivered". A few seconds of errors is enough to stop the pipeline.

Workarounds:

  • Raise Retry data fetch every and Pause stream after to cover your normal deploy time.
  • Answer with a 2xx as soon as the signature checks out, and process afterwards.
  • Alert on a paused stream and reactivate it once your endpoint is healthy.

How Hookdeck Can Help: Hookdeck acknowledges each delivery when it arrives and stores it, then delivers to your service on a retry schedule you choose. Your downtime no longer counts against the stream's retry budget.

No event types to route on

The Problem: One handler receives transfers, contract events and whole blocks, and has to guess which is which.

Why It Happens: Quicknode sends the output of a filter, not a typed event. Nothing in the body or headers says which stream or webhook produced it.

Workarounds:

  • Give each stream or webhook its own URL path.
  • If several share a path, add a custom header on Streams destinations, or have your filter function add an identifying field.

How Hookdeck Can Help: A separate Hookdeck source per stream or webhook keeps each one's deliveries apart, and filters can route on fields in the body when they share a source.

Duplicates and ordering

The Problem: The same block or transfer is processed twice, or blocks are applied out of order after a retry.

Why It Happens: Retries can resend a payload your service already handled. Quicknode Webhooks also lists reorg handling as a feature without saying how a chain reorganisation shows up in deliveries. X-QN-Nonce is described as a unique string per message, but Quicknode doesn't say whether a retry keeps the same nonce.

Workarounds:

  • Deduplicate on values from the payload, such as block number and hash, or transaction hash and log index.
  • If you see a different hash at a height you already stored, treat it as a reorganisation, not a duplicate.
  • Store each record with an upsert keyed on those values.

How Hookdeck Can Help: Hookdeck deduplication can drop repeat deliveries on body fields you choose before they reach your service.

Best practices

Verify against the raw body

The signature covers the exact bytes Quicknode signed. In Express, use express.raw({ type: '*/*' }) on the webhook route. In a Next.js route handler, use Buffer.from(await request.arrayBuffer()). In FastAPI, read await request.body(). Parse JSON only after verification passes, and don't re-serialise it to verify.

Mind the order of the signed parts

The signed content is nonce, then timestamp, then body, with no dots or commas. A verifier built as timestamp.body or nonce.timestamp.body never matches.

Acknowledge fast and process asynchronously

A Streams destination waits for your configured timeout, 30 seconds by default, and won't move on until you answer. Verify, persist the payload, return 200, and do the work afterwards. See why to process webhooks asynchronously.

Make processing idempotent

Use on-chain identifiers as the deduplication key and write with upserts, so a retry or reorganisation does not create a second record. See our guide to webhook idempotency.

Layer the other controls

Streams destinations support mTLS with a Quicknode client certificate (CN=quicknode.com), and both products deliver from 141.148.40.227. These prove the connection came from Quicknode. The HMAC signature proves which stream or webhook the payload belongs to, so keep verifying it.

Conclusion

Quicknode signs each delivery with a hex HMAC-SHA256 of the nonce, the timestamp and the body, run together with no separator. Keep compression off so the body you verify is the body that was signed, give each stream or webhook its own path, and acknowledge quickly so a stream doesn't pause on a few failed retries.

Hookdeck Event Gateway verifies every Quicknode signature and holds each payload until your service has processed it, so a slow deploy doesn't stop your stream.

Get started with Hookdeck for free and handle Quicknode webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.