Create a test URL

Test QuickNode Streams webhooks

QuickNode Streams pushes blockchain data, filtered by a function you write, to a webhook destination. The body's shape depends entirely on the dataset and the filter you choose, so the fastest way to learn it is to capture a real delivery and use it as your test fixture.

Create a test URL

Point your QuickNode webhook at it and see a real event.

What QuickNode sends

A Streams delivery is a POST with a JSON body. The payload is whatever your Stream's dataset and filter produce, so this guide does not show one: capture your own. Streams may compress the body with gzip and say so in Content-Encoding, which the inspector shows, so you can tell at a glance whether your handler needs to decompress.

Headers

Header Example value Meaning
X-QN-Nonce 6f1c…9a2e A unique value per message, there to stop replays. Part of the signed string.
X-QN-Timestamp 1760011200 When the message was signed. Part of the signed string.
X-QN-Signature b2e4…07cd HMAC-SHA256 of nonce, timestamp and payload, as hex.
Content-Encoding gzip Present when the body is compressed. The signature covers the decompressed JSON.

Verify the signature

The signature is an HMAC-SHA256, keyed with the Stream's security token from its Settings tab, over X-QN-Nonce, then X-QN-Timestamp, then the payload, concatenated with nothing between them, as hex. When the body is gzipped, the payload is the decompressed JSON, not the bytes on the wire.

import (
	"compress/gzip"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
)

// readStreamsBody returns the payload that was signed: decompressed if needed.
func readStreamsBody(r *http.Request) ([]byte, error) {
	if r.Header.Get("Content-Encoding") != "gzip" {
		return io.ReadAll(r.Body)
	}
	zr, err := gzip.NewReader(r.Body)
	if err != nil {
		return nil, err
	}
	defer zr.Close()
	return io.ReadAll(zr)
}

func validQuickNodeSignature(r *http.Request, payload []byte, securityToken string) bool {
	mac := hmac.New(sha256.New, []byte(securityToken))
	mac.Write([]byte(r.Header.Get("X-QN-Nonce")))
	mac.Write([]byte(r.Header.Get("X-QN-Timestamp")))
	mac.Write(payload)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(r.Header.Get("X-QN-Signature")))
}
import { createHmac, timingSafeEqual } from "node:crypto";

// payload is the body as a string after decompression. Express body parsers
// such as express.text() inflate gzip themselves, so don't gunzip it again.
export function validQuickNodeSignature(nonce, timestamp, payload, signature, securityToken) {
  const expected = createHmac("sha256", Buffer.from(securityToken))
    .update(nonce + timestamp + payload)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature ?? "", "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Respond quickly

Reply as soon as the batch is stored and process it afterwards. The docs don't set a maximum age for X-QN-Timestamp, so if replays matter to you, reject old timestamps and nonces you have already seen.

Common mistakes

  • Hashing the gzip bytes. The signature covers the decompressed JSON.
  • Decompressing twice in Express, where body parsers already inflate gzip.
  • Getting the order wrong. It is nonce, then timestamp, then payload, with no separator.
  • Re-serialising the payload instead of using the body string as received.

Try it with a real event

Create a test URL, set it as a Stream's webhook destination, and start the Stream. The signed string includes the nonce and timestamp, so the signature verifier has no QuickNode preset; use the code above against your capture instead.