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