Status: stable - schema bumps require a documented migration window.
Audience: subscriber implementers. The relay sends; the subscriber receives.
Producer: purple-wolf-relay v0.1.0+.
This document is the only contract between the relay and a subscriber. You do not need to read the relay's source to implement a compliant subscriber.
Every webhook body is a single JSON object matching this shape:
{
"schema": "purple-wolf.audit/v1",
"event_id": "01HXYZ7F8GZG23P4M2RKN9Y4QF",
"delivery_id": "01HXYZ7F8GZG23P4M2RKN9Y4QG",
"delivered_at": "2026-05-25T17:30:02.123Z",
"attempt": 1,
"labels": { "environment": "prod", "service": "checkout-api", "tenant": "acme" },
"source": {
"middleware": "strict-waf",
"router": "checkout",
"entry_point": "web",
"relay_instance": "relay-abc-1"
},
"event": {
"host": "checkout.acme.example",
"path": "/api/v1/cart",
"query": "id=1%27+OR+%271%27%3D%271",
"method": "POST",
"source_ip": "203.0.113.7",
"action": "block",
"blocked_rule": "injection/sqli",
"blocked_severity": "critical",
"blocked_detail": "SQLi in field: …",
"would_block_rules": ["reputation/rate_limited"]
}
}| Field | Type | Required | Notes |
|---|---|---|---|
schema |
string | yes | Always "purple-wolf.audit/<MAJOR>". v1 today. Reject if the major doesn't match what your subscriber implements. |
event_id |
ULID string | yes | Stable across retries. Subscribers MUST dedupe on this. |
delivery_id |
ULID string | yes | Changes per attempt. Useful only for subscriber-side logging - do not use for dedup. |
delivered_at |
RFC 3339 UTC | yes | When the relay started this attempt. Not the same as event.* timestamps. |
attempt |
int ≥ 1 | yes | 1-based retry counter; matches the X-PurpleWolf-Attempt header. |
labels |
object<string,string> | omitted-when-empty | Operator-supplied labels from the source Middleware. See configuration.md §Labels. |
source.middleware |
string | null | yes | Traefik Middleware name (without the @file / @kubernetescrd suffix). May be null if the relay couldn't extract it from the log line. |
source.router |
string | null | yes | Traefik router name (suffix stripped). |
source.entry_point |
string | null | yes | Traefik entry-point name. |
source.relay_instance |
string | yes | Stable identifier for this relay instance. Useful for cluster-wide dedup when running multiple relays. |
event |
object | yes | Verbatim purple-wolf audit fields. Treat as forward-compatible: new optional fields may appear in minor versions without a schema bump. |
The relay guarantees:
- All v1 envelopes will contain the fields listed above.
- New optional fields may be added under
event.*orsource.*without a schema bump. Subscribers MUST ignore unknown fields. - Existing fields will not change type or be removed within v1.
Anything that would break those guarantees ships as purple-wolf.audit/v2,
side-by-side with v1 for a documented migration window (see §6).
POST <subscriber.url>
Content-Type: application/json
User-Agent: purple-wolf-relay/<version>
X-PurpleWolf-Schema: purple-wolf.audit/v1
X-PurpleWolf-Event-Id: 01HXYZ...
X-PurpleWolf-Delivery-Id: 01HXYZ...
X-PurpleWolf-Attempt: 1
X-PurpleWolf-Timestamp: 1748194202
X-PurpleWolf-Signature: sha256=<lowercase hex HMAC>
The relay does NOT follow redirects (3xx responses are treated as delivery failure). Configure your URL to be the final destination.
| Status | Relay interpretation |
|---|---|
2xx |
Delivered; relay marks the attempt complete and discards the envelope. |
408 Request Timeout |
Retryable; relay re-attempts per its retry policy. |
429 Too Many Requests |
Retryable; delay follows the configured exponential-backoff schedule. Retry-After is not currently interpreted. |
5xx |
Retryable; relay re-attempts per its retry policy. |
3xx |
Failure. Relay does not follow redirects. |
4xx (except 408, 429) |
Permanent failure. Envelope goes to the DLQ; no retries. |
| network error / connection reset | Retryable. |
| timeout (default 30s) | Retryable. |
Response bodies are not inspected - keep them small to save bandwidth.
Algorithm: HMAC-SHA256.
Signed payload: the literal byte concatenation
<X-PurpleWolf-Timestamp>.<request body bytes>
where the timestamp is the ASCII decimal-string value of the
X-PurpleWolf-Timestamp header (Unix seconds). The relay enforces
that the body is sent verbatim; subscribers MUST sign the raw request
body, not a re-serialized JSON form.
Header format:
X-PurpleWolf-Signature: sha256=<lowercase hex>
Subscriber requirements:
- Constant-time compare. Use a constant-time HMAC compare (e.g.,
hmac.compare_digestin Python,subtle.ConstantTimeComparein Go,timingSafeEqualin Node). Plain string equality leaks timing. - Verify timestamp skew. Reject if
|now - timestamp| > 300s(default 5 minutes). The timestamp is part of the HMAC input, so a captured request can't be replayed outside the window. - Use the raw body. If your framework parses the body before handing it to you, re-serialization will change byte-for-byte output and the signature will not verify.
Secret rotation. Subscribers SHOULD support an overlap window during rotation: accept signatures verified by either the old or new secret while the operator flips the relay's config. A safe pattern: keep two secrets in your subscriber and accept either; expire the old one after the rotation window closes.
The relay retries a delivery in process, so a subscriber can see the same
event more than once. It is not a durable at-least-once queue: bounded channel
drops, process crashes, and the in-memory DLQ can lose an event. Subscribers
achieve at-most-once side effects for observed retries by deduping on
event_id.
Required:
- Store
event_idfor at least the relay's max retry window (default ~10 minutes formax_attempts=8, max_delay_ms=600_000). 24 hours is a safe default that absorbs longer outages on the subscriber side. - On a duplicate, respond
200 OKwith no side-effect - the relay treats this as "delivered" and stops retrying.
Recommended:
- Even with dedup, make your handler idempotent at the side-effect level. Dedup catches retries; idempotent handlers catch the case where you 200'd before persisting and crashed.
The relay's default retry policy:
- Exponential backoff with ±20% jitter.
- Base delay: 500ms.
- Max delay: 10 minutes (
max_delay_ms). - Max attempts: 8 (configurable per-subscriber).
- After max attempts the envelope is moved to the per-subscriber DLQ.
Subscribers MUST NOT assume any specific retry schedule. The
contract is "at least once for up to max_attempts × max_delay_ms",
not a specific backoff curve.
- The
schemafield's MAJOR identifies the protocol generation. - Within a MAJOR, only additive changes (new optional fields) are permitted. Subscribers MUST ignore unknown fields.
- Removing a field, changing a type, or changing the semantics of a field requires a MAJOR bump.
- During a migration window the relay can be configured to deliver
both
vNandvN+1envelopes to the same subscriber URL with differentschemaheaders; subscribers can fan-in onschema.
Three small reference implementations live in
crates/purple-wolf-relay/examples/subscribers/.
Each one verifies the signature, checks the timestamp skew, dedupes
on event_id, and responds 200 on success / 5xx on transient error.
import hashlib, hmac, json, os, time
from collections import OrderedDict
from flask import Flask, request, abort
SECRET = os.environ["PURPLEWOLF_SECRET"].encode()
SKEW_S = 300 # 5 minutes
SEEN: "OrderedDict[str, float]" = OrderedDict() # event_id → ts; replace with Redis in prod
SEEN_CAP = 10_000
app = Flask(__name__)
@app.post("/webhook")
def receive():
ts = request.headers.get("X-PurpleWolf-Timestamp", "")
sig = request.headers.get("X-PurpleWolf-Signature", "")
eid = request.headers.get("X-PurpleWolf-Event-Id", "")
if not (ts.isdigit() and sig.startswith("sha256=") and eid):
abort(400)
if abs(time.time() - int(ts)) > SKEW_S:
abort(401) # replay protection
body = request.get_data()
expected = "sha256=" + hmac.new(
SECRET, f"{ts}.".encode() + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
if eid in SEEN:
return ("", 200) # dedup; idempotent ack
SEEN[eid] = time.time()
if len(SEEN) > SEEN_CAP:
SEEN.popitem(last=False)
event = json.loads(body)
# ... your handler ...
return ("", 200)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
var (
secret = []byte(os.Getenv("PURPLEWOLF_SECRET"))
seen sync.Map // event_id → seen-at; for prod use a TTL store
skew = 300 * time.Second
)
func receive(w http.ResponseWriter, r *http.Request) {
tsHdr := r.Header.Get("X-PurpleWolf-Timestamp")
sig := r.Header.Get("X-PurpleWolf-Signature")
eid := r.Header.Get("X-PurpleWolf-Event-Id")
if tsHdr == "" || !strings.HasPrefix(sig, "sha256=") || eid == "" {
http.Error(w, "bad headers", http.StatusBadRequest); return
}
ts, err := strconv.ParseInt(tsHdr, 10, 64)
if err != nil {
http.Error(w, "bad ts", http.StatusBadRequest); return
}
if abs(time.Now().Unix()-ts) > int64(skew.Seconds()) {
http.Error(w, "skew", http.StatusUnauthorized); return
}
body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sig)) {
http.Error(w, "sig", http.StatusUnauthorized); return
}
if _, dup := seen.LoadOrStore(eid, time.Now()); dup {
w.WriteHeader(http.StatusOK); return
}
// ... your handler ...
w.WriteHeader(http.StatusOK)
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }
func main() {
http.HandleFunc("/webhook", receive)
_ = http.ListenAndServe(":8080", nil)
}import { Hono } from "hono";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = Buffer.from(process.env.PURPLEWOLF_SECRET!);
const SKEW_S = 300;
const seen = new Map<string, number>();
const SEEN_CAP = 10_000;
const app = new Hono();
app.post("/webhook", async (c) => {
const ts = c.req.header("x-purplewolf-timestamp") ?? "";
const sig = c.req.header("x-purplewolf-signature") ?? "";
const eid = c.req.header("x-purplewolf-event-id") ?? "";
if (!/^\d+$/.test(ts) || !sig.startsWith("sha256=") || !eid) {
return c.text("bad headers", 400);
}
if (Math.abs(Date.now() / 1000 - Number(ts)) > SKEW_S) {
return c.text("skew", 401);
}
const body = Buffer.from(await c.req.arrayBuffer());
const expected =
"sha256=" +
createHmac("sha256", SECRET).update(`${ts}.`).update(body).digest("hex");
if (
expected.length !== sig.length ||
!timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
) {
return c.text("sig", 401);
}
if (seen.has(eid)) return c.text("", 200);
seen.set(eid, Date.now());
if (seen.size > SEEN_CAP) seen.delete(seen.keys().next().value!);
// ... your handler ...
return c.text("", 200);
});
export default app;- Verify HMAC signature on every request. Do not trust source IP, Host header, or any other transport hint.
- Compare HMACs in constant time.
- Check
|now - X-PurpleWolf-Timestamp| ≤ 300sfor replay protection. Sync your clock (NTP/Chrony). - Dedupe on
event_id. At-least-once → exactly-once shifts the burden to you; budget for it. - Sign the raw request body. Don't re-serialize after parsing.
- Respond within 30s (default relay timeout). Long-running side effects belong in a queue, not in the request handler.
- Support an overlap window during secret rotation. Two secrets simultaneously valid is the standard pattern.
- Make your handler idempotent at the side-effect level (DB writes, message emits) - dedup catches retries, idempotency catches partial successes.
- On unrecoverable application errors, return 5xx (the relay retries) - not 4xx (the relay sends to DLQ).
- Bind your endpoint TLS-only in production. The relay's HMAC gives integrity + authenticity, not confidentiality.
| Subscriber outcome | Relay action |
|---|---|
| 2xx | Mark delivered. |
| 408, 429, 5xx | Retry with backoff. |
| 4xx (not 408/429) | Move to DLQ. Operator action required. |
| 3xx | Treated as failure. Move to DLQ. (Relay does not follow redirects.) |
| Network error / connection reset | Retry with backoff. |
| TLS handshake failure | Retry with backoff. |
| Subscriber timeout (default 30s) | Retry with backoff. |
| Max attempts exhausted | Move to DLQ. |
See configuration.md for how operators set
the labels field this protocol carries. See the relay README for
operational concerns (DLQ replay, secret rotation procedure, metrics).