SDKs
Webhooks
Receiver patterns per language. Express in Node, Flask in Python, net/http in Go. Constant-time HMAC signature verification, replay-safe handlers.
Last updated
TL;DR. Each SDK ships a verifySignature helper that returns a typed Event on
success and raises a typed MatterSignatureError on failure. Use the raw request
body — re-serialized JSON will not verify. The helper enforces a 5-minute timestamp
window and a constant-time compare.
Matter webhook events are signed with HMAC-SHA256. Every POST to your endpoint carries
a Matter-Signature header of the form t=<unix>,v1=<hex>. The signed payload is
<unix>.<raw body>. Receivers must:
- Read the raw request body. Do not re-serialize.
- Pull the timestamp out of
Matter-Signatureand reject if outside a 5-minute window. - Compute HMAC-SHA256 of
<unix>.<raw body>with the endpoint secret. - Constant-time compare against the
v1=value. - Parse the body into an
Eventonly after verification.
The SDK helpers do all five steps. Hand-rolling them is allowed but not recommended.
Node — Express
import express from "express";
import { MatterClient, MatterSignatureError } from "@mattermode/node";
const matter = new MatterClient({
apiKey: process.env.MATTER_API_KEY!,
version: "2026-05-01",
});
const app = express();
// `express.raw` preserves the raw body. Do not use `express.json()` on this route.
app.post(
"/webhooks/matter",
express.raw({ type: "application/json" }),
async (req, res) => {
try {
const event = matter.webhooks.verifySignature(
req.body, // Buffer — raw body
req.header("Matter-Signature")!,
process.env.MATTER_WEBHOOK_SECRET!,
);
// Idempotently handle. Same event.id may arrive twice on receiver retry.
await handle(event);
res.status(204).end();
} catch (err) {
if (err instanceof MatterSignatureError) {
res.status(401).end();
return;
}
// Hand back a 5xx so Matter retries — your handler is broken, the event is fine.
res.status(500).end();
}
},
);
async function handle(event: Event) {
switch (event.type) {
case "entity.state_changed": /* ... */ break;
case "filing.completed": /* ... */ break;
}
}Next.js route handler
// app/api/webhooks/matter/route.ts
import { NextResponse } from "next/server";
import { MatterClient, MatterSignatureError } from "@mattermode/node";
const matter = new MatterClient({ apiKey: "...", version: "2026-05-01" });
export async function POST(req: Request) {
const body = await req.text(); // raw — do not call req.json()
const sig = req.headers.get("matter-signature")!;
try {
const event = matter.webhooks.verifySignature(
body,
sig,
process.env.MATTER_WEBHOOK_SECRET!,
);
await handle(event);
return new NextResponse(null, { status: 204 });
} catch (err) {
if (err instanceof MatterSignatureError) {
return new NextResponse(null, { status: 401 });
}
return new NextResponse(null, { status: 500 });
}
}Python — Flask
import os
from flask import Flask, request, abort
from matter import Matter
from matter.errors import MatterSignatureError
matter = Matter(api_key=os.environ["MATTER_API_KEY"], version="2026-05-01")
app = Flask(__name__)
@app.post("/webhooks/matter")
def receive():
raw = request.get_data() # bytes — raw body
sig = request.headers.get("Matter-Signature", "")
try:
event = matter.webhooks.verify_signature(
payload=raw,
signature_header=sig,
secret=os.environ["MATTER_WEBHOOK_SECRET"],
)
except MatterSignatureError:
abort(401)
handle(event)
return "", 204FastAPI
from fastapi import FastAPI, Request, HTTPException
from matter import Matter
from matter.errors import MatterSignatureError
matter = Matter(api_key="...", version="2026-05-01")
app = FastAPI()
@app.post("/webhooks/matter", status_code=204)
async def receive(request: Request):
raw = await request.body()
sig = request.headers.get("matter-signature", "")
try:
event = matter.webhooks.verify_signature(raw, sig, MATTER_WEBHOOK_SECRET)
except MatterSignatureError:
raise HTTPException(401)
await handle(event)Go — net/http
package main
import (
"io"
"net/http"
"os"
"github.com/matterhq/matter-go"
"github.com/matterhq/matter-go/webhook"
)
var secret = os.Getenv("MATTER_WEBHOOK_SECRET")
func receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
event, err := webhook.Verify(body, r.Header.Get("Matter-Signature"), secret)
if err != nil {
var sigErr *matter.SignatureError
if errors.As(err, &sigErr) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
http.Error(w, "internal", http.StatusInternalServerError)
return
}
if err := handle(event); err != nil {
// 5xx → Matter retries
http.Error(w, "handler failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func main() {
http.HandleFunc("/webhooks/matter", receive)
http.ListenAndServe(":8080", nil)
}Idempotent handlers
Matter retries delivery on any non-2xx response, with exponential backoff up to 24 hours.
Your handler must be idempotent on event.id. A simple pattern:
| Storage | Pattern |
|---|---|
| Postgres | INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING — branch on rowCount |
| Redis | SET event:<id> 1 NX EX 86400 — branch on the OK reply |
| Upstash / KV | kv.set(`event:${id}`, 1, { nx: true, ex: 86400 }) — branch on the OK reply |
If the storage write succeeds, run the handler. If it didn't (the event already
processed), return 204 and move on.
Strict per-entity ordering
Events for a given entity carry a strictly-increasing sequence. If your handler must
process events in order, dedupe on event.id and order on event.sequence per
event.data.entity. Cross-entity ordering is best-effort — do not rely on it.
// minimum work to honor per-entity order
if (event.sequence <= lastSeenFor(event.data.entity)) return; // out of order — skip
await handle(event);
await persistSeen(event.data.entity, event.sequence);Replay window
The signature timestamp window is 5 minutes by default. Configure on the helper if your clock is unreliable:
matter.webhooks.verifySignature(body, sig, secret, { toleranceSeconds: 600 });matter.webhooks.verify_signature(body, sig, secret, tolerance_seconds=600)webhook.VerifyWithTolerance(body, sig, secret, 10*time.Minute)A wider window weakens replay protection — only widen on receivers with documented clock drift.