When a webhook trigger has signing turned on, Natiq refuses any delivery whose signature does not match its body. This page gives the exact scheme, so your sender can sign correctly and so you can reproduce the check in tests or in your own receiver.
Which direction does this apply to?
Signing protects inbound deliveries, from your system to a flow's webhook URL (see Inbound triggers). Natiq calling your bot's webhook is different: it authenticates with HTTP Basic, not a signature. See External agents.
The scheme
| Part | Value |
|---|---|
| Header | X-Signature-SHA256 by default, or the name chosen when the trigger was created (signatureHeader). Read signatureHeader on the trigger to be sure. |
| Algorithm | HMAC-SHA256 |
| Key | The trigger's signing secret, as UTF-8 bytes. Shown once when you create or rotate. |
| Message | The raw request body, byte for byte, exactly as sent. |
| Encoding | Lowercase hexadecimal, 64 characters. |
| Prefix | Optional sha256=. Natiq accepts the value with or without it, in any letter case, and ignores surrounding whitespace. |
Natiq compares in constant time. A missing header gives 401 WEBHOOK_SIGNATURE_INVALID, and so does a wrong one.
Sign the bytes you send
Compute the signature over the exact byte string you put on the wire. If you sign a JSON object and then let your HTTP client serialise it again, key order or whitespace can change and the signature will not match. Serialise once, sign that, send that.
Sign a delivery
import { createHmac } from 'node:crypto'
const body = JSON.stringify({ order_id: 'A-1001' })
const signature = createHmac('sha256', process.env.NATIQ_WEBHOOK_SECRET)
.update(body)
.digest('hex')
await fetch(process.env.NATIQ_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Signature-SHA256': signature },
body,
})import hashlib, hmac, json, os, requests
body = json.dumps({"order_id": "A-1001"}, separators=(",", ":")).encode()
signature = hmac.new(os.environ["NATIQ_WEBHOOK_SECRET"].encode(), body, hashlib.sha256).hexdigest()
requests.post(
os.environ["NATIQ_WEBHOOK_URL"],
data=body, # send the same bytes you signed
headers={"Content-Type": "application/json", "X-Signature-SHA256": signature},
timeout=30,
)package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"os"
)
func main() {
body := []byte(`{"order_id":"A-1001"}`)
mac := hmac.New(sha256.New, []byte(os.Getenv("NATIQ_WEBHOOK_SECRET")))
mac.Write(body)
signature := hex.EncodeToString(mac.Sum(nil))
req, _ := http.NewRequest("POST", os.Getenv("NATIQ_WEBHOOK_URL"), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Signature-SHA256", signature)
http.DefaultClient.Do(req)
}Verify a signature
Use this when you receive signed webhooks of your own in the same format, or in tests that mimic Natiq's check. Always read the raw body before any JSON parsing, and always compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, rawBody, header) {
if (!header) return false
let presented = header.trim().toLowerCase()
if (presented.startsWith('sha256=')) presented = presented.slice(7)
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(presented)
return a.length === b.length && timingSafeEqual(a, b)
}
// Express: capture the raw body first
// app.use(express.raw({ type: 'application/json' }))
// verify(secret, req.body, req.get('X-Signature-SHA256'))import hashlib, hmac
def verify(secret: str, raw_body: bytes, header: str | None) -> bool:
if not header:
return False
presented = header.strip().lower()
if presented.startswith("sha256="):
presented = presented[7:]
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), presented.encode())
# Flask: verify(secret, request.get_data(), request.headers.get("X-Signature-SHA256"))package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func Verify(secret string, rawBody []byte, header string) bool {
presented := strings.ToLower(strings.TrimSpace(header))
presented = strings.TrimPrefix(presented, "sha256=")
if presented == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal is a constant-time comparison
return hmac.Equal([]byte(expected), []byte(presented))
}
// net/http: body, _ := io.ReadAll(r.Body); Verify(secret, body, r.Header.Get("X-Signature-SHA256"))Debugging a mismatch
- Print the exact bytes you sent and sign them again by hand with
openssl:printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET". The hex digest must equal the header. - Check the header name. If the trigger has a custom
signatureHeader, the default header is ignored. AnullsignatureHeadermeansX-Signature-SHA256. - Check that the secret is the current one. Rotating with
rotateSecretreplaces it, and the old one stops working within about a minute. - Make sure no proxy, middleware or
Content-Encodingchanges the body between signing and sending.
Replay protection
The signature covers only the body; there is no timestamp. To make retries and replays harmless, send an X-Natiq-Delivery-Id header with a unique value per event. Natiq returns the original run for a repeated id. The delivery id is not part of the signature, so keep it unguessable if replays matter to you.