API · Events · Webhooks
Signing
HMAC-SHA256 signature verification, replay-attack mitigation, and secret rotation.
Last updated
TL;DR. Every delivery carries Matter-Signature: t=<unix>,v1=<hex>. The signed
payload is t + "." + raw_body, signed with HMAC-SHA256 using the endpoint's
signing_secret. Reject anything older than 5 minutes. Compare in constant time.
Matter signs every webhook delivery so your handler can prove the request originated from Matter and was not modified in transit. Verification has three steps: parse the header, recompute the HMAC, compare in constant time.
Header anatomy
Matter-Signature: t=1745251200,v1=2c1f8b7a...e1| Field | Meaning |
|---|---|
t | Unix epoch seconds when the signature was computed |
v1 | Lowercase hex HMAC-SHA256 of <t>.<raw_body> using the endpoint's signing_secret |
The header may carry additional comma-separated vN versions in the future. Ignore
versions you don't recognise; verify the highest you support.
raw_body is the byte-for-byte HTTP request body as Matter sent it. Re-serialising
the parsed JSON will not match — your framework must give you access to the unparsed
buffer (req.rawBody in Express, request.body as bytes in FastAPI, io.ReadAll(r.Body)
in net/http).
Verifying the signature
import crypto from "node:crypto";
export function verifyMatterSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=")),
);
const ts = parts.t;
const sig = parts.v1;
if (!ts || !sig) throw new Error("Malformed signature header");
// 5-minute replay window
const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(ts, 10));
if (age > 300) throw new Error("Timestamp outside replay window");
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Signature mismatch");
}
}import hashlib
import hmac
import time
def verify_matter_signature(raw_body: bytes, header: str, secret: str) -> None:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts.get("t"), parts.get("v1")
if not ts or not sig:
raise ValueError("Malformed signature header")
# 5-minute replay window
if abs(int(time.time()) - int(ts)) > 300:
raise ValueError("Timestamp outside replay window")
signed_payload = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
raise ValueError("Signature mismatch")package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
func VerifyMatterSignature(rawBody []byte, header, secret string) error {
var ts, sig string
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
ts = kv[1]
case "v1":
sig = kv[1]
}
}
if ts == "" || sig == "" {
return errors.New("malformed signature header")
}
sent, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp: %w", err)
}
if delta := time.Now().Unix() - sent; delta > 300 || delta < -300 {
return errors.New("timestamp outside replay window")
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.", ts)
mac.Write(rawBody)
expected := mac.Sum(nil)
got, err := hex.DecodeString(sig)
if err != nil {
return fmt.Errorf("invalid signature hex: %w", err)
}
if !hmac.Equal(got, expected) {
return errors.New("signature mismatch")
}
return nil
}Always use a constant-time comparator (crypto.timingSafeEqual,
hmac.compare_digest, hmac.Equal). A == comparison leaks timing information that
lets an attacker recover the signature byte by byte.
Replay-attack mitigation
A captured request replayed within the 5-minute window will still verify against the
HMAC. The timestamp check closes that gap: any delivery with |now - t| > 300 seconds
is rejected.
If you operate behind a queue or buffer that may delay processing past 5 minutes, verify the signature at the edge before queueing — not at the consumer.
For stronger guarantees, also persist event.id and reject duplicates. See
Retries › Idempotency for the receiver-side
dedupe pattern.
Rotating secrets
POST /v1/webhook_endpoints/whe_.../rotate_secretThe response carries the new signing_secret — again, shown once. The old secret
continues to sign deliveries for 24 hours while you roll the new one through your
deployment.
Call rotate. The endpoint now has two valid secrets: the old one (expiring in 24h) and the new one.
Deploy with both. Update your verifier to accept either secret. Roll to all instances.
Drop the old. After 24 hours Matter stops signing with the old secret. Remove it from your verifier on the next deploy.
If you suspect a secret has leaked, rotate immediately and also call
POST /v1/webhook_endpoints/whe_.../revoke_secret to invalidate the old one before the
24-hour window expires.
Common mistakes
Related
- Retries & replay — what happens after verification fails or your handler returns non-2xx.
- Event catalog — the shape of what you're verifying.
POST /webhook_endpoints/{id}/rotate_secret