Risk event webhooks
Configure an organization webhook, verify signed v1 deliveries, and handle retries safely.
Receive a signed HTTP POST when a subscribed plan, task, or pull-request risk event lands in your organization's room. The default threshold is high (high and critical); you can select critical only. This is an integration for organization administrators.
Setup and test
Organization Settings links to this guide. Webhook configuration currently uses the same-origin, authenticated gateway API; this guide does not imply that a configuration form is available in Settings.
Use your signed-in organization-admin browser session on https://archdev.ai. Requests to another origin are rejected. Your receiver must have a publicly reachable HTTPS URL; localhost, private addresses, and unsafe DNS destinations are rejected.
PUT /api/archdev/org/webhookwith JSON{"url":"https://example.com/hooks/archdev","enabled":false,"min_risk":"high","events":["plan","task","pr"]}.eventsselects event families, not individual lifecycle names.POST /api/archdev/org/webhook/secret/rotate. Copy the returnedsecretinto your receiver's secret store; it is shown only in this response. Do not log it. The secret is a literal UTF-8 string, not hex-decoded key material.- Install signature verification at the receiver, then
PUT /api/archdev/org/webhookwith the same settings andenabled:true. Saving without a secret leaves delivery disabled; rotating alone does not enable it. POST /api/archdev/org/webhook/test. The response reportsdeliveredandstatus_code; confirm your receiver accepted the request. It sends a signed synthetic critical-risk event from the first subscribed family, withtest:trueand a uniquewebhook-test-…id. A test failure does not prove production delivery works; check the receiver response and that the organization's webhook automation is running.
Send JSON with Content-Type: application/json. GET /api/archdev/org/webhook returns settings and secret_set, never the stored secret. DELETE /api/archdev/org/webhook removes the configuration and its automation. For rotation, briefly accept both old and new secrets at your receiver, rotate, then remove the old secret after in-flight requests finish. Each current delivery contains one signature; verifiers support multiple v1 candidates.
Payload v1
The public contract is archdev-risk-webhook-v1.yaml. Breaking changes use a new version. Signed contract fixtures cover all three families.
| Field | Meaning |
|---|---|
id |
Message id; equals the webhook-id header and room.message_id. |
type, version |
archdev.risk_event, 1. |
created_at |
RFC 3339 UTC creation time. |
org.id |
Owning organization. |
event |
plan.created, plan.started, plan.updated; task.created, task.started, task.updated, task.closed; pr.created, pr.updated, pr.closed. pr.merged is a deprecated accepted alias for pr.closed. |
subject |
Discriminated subject: type:plan with path; type:task with id; type:pull_request with number, title, url. |
repository.full_name |
owner/repo. |
risk |
combined: low, medium, high, critical; uncertainty and consequence: low, medium, high. |
summary |
Human-readable message content. Treat it as untrusted input when rendering or acting on it. |
room |
thread_id, message_id, url. |
test |
Optional true for a synthetic test; do not trigger production actions for it. |
Headers and signature verification
Content-Type is application/json. ArchDev-Webhook-Id is the body's id. ArchDev-Signature has the form t=<unix seconds>,v1=<64 hex characters>[,v1=…].
Verify before parsing or processing. HMAC-SHA256 signs t + "." + raw_body using exact request bytes, including whitespace and any trailing newline. Do not reserialize JSON. Capture bytes before JSON middleware; in Go read r.Body, in Node use a Buffer, and in Python use bytes. After verification, validate type, version, your organization, and equality of the signed body id to the webhook-id header.
These functions return a boolean and accept a list of active secrets. They reject timestamps more than 300 seconds in either direction and compare signatures in constant time. Pass the current Unix time in production; the explicit now argument exists so the historical fixtures can be tested at their signing time. Reject the request if verification returns false.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyRiskWebhook(raw, header, secrets, now = Math.floor(Date.now() / 1000)) {
if (typeof header !== "string") return false;
let timestamp;
const signatures = [];
for (const part of header.split(",")) {
const match = /^(t|v1)=(.*)$/.exec(part.trim());
if (!match) return false;
const [, key, value] = match;
if (key === "t") {
if (!/^\d+$/.test(value)) return false;
timestamp = Number(value);
if (!Number.isSafeInteger(timestamp)) return false;
} else {
if (!/^[a-fA-F0-9]{64}$/.test(value)) return false;
signatures.push(Buffer.from(value, "hex"));
}
}
if (timestamp === undefined || Math.abs(now - timestamp) > 300) return false;
for (const secret of secrets) {
if (!secret) continue;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`).update(raw).digest();
for (const candidate of signatures) {
if (timingSafeEqual(candidate, expected)) return true;
}
}
return false;
}
Python
import hashlib
import hmac
import re
import time
def verify_risk_webhook(raw, header, secrets, now=None):
if not isinstance(header, str):
return False
timestamp = None
signatures = []
for part in header.split(","):
match = re.fullmatch(r"(t|v1)=(.*)", part.strip())
if not match:
return False
key, value = match.groups()
if key == "t":
if not re.fullmatch(r"[0-9]+", value):
return False
try:
timestamp = int(value)
except ValueError:
return False
else:
if not re.fullmatch(r"[a-fA-F0-9]{64}", value):
return False
signatures.append(bytes.fromhex(value))
now = int(time.time()) if now is None else now
if timestamp is None or abs(now - timestamp) > 300:
return False
for secret in secrets:
if not secret:
continue
expected = hmac.new(secret.encode("utf-8"),
str(timestamp).encode("ascii") + b"." + raw,
hashlib.sha256).digest()
if any(hmac.compare_digest(candidate, expected) for candidate in signatures):
return True
return False
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func verifyRiskWebhook(raw []byte, header string, secrets []string, now time.Time) bool {
var timestamp int64 = -1
var signatures [][]byte
for _, part := range strings.Split(header, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok { return false }
switch key {
case "t":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 { return false }
timestamp = parsed
case "v1":
candidate, err := hex.DecodeString(value)
if err != nil || len(candidate) != sha256.Size { return false }
signatures = append(signatures, candidate)
default:
return false
}
}
// Compare times without subtracting arbitrary attacker-controlled int64s.
if timestamp < 0 || time.Unix(timestamp, 0).Before(now.Add(-300*time.Second)) ||
time.Unix(timestamp, 0).After(now.Add(300*time.Second)) { return false }
for _, secret := range secrets {
if secret == "" { continue }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
mac.Write(raw)
for _, candidate := range signatures {
if hmac.Equal(candidate, mac.Sum(nil)) { return true }
}
}
return false
}
Retries and idempotency
Delivery is at-least-once, not exactly-once. Within one run the sender makes at most three immediate attempts, each with a ten-second timeout, retrying transport failures and 5xx responses. There is currently no backoff within that run. Any 2xx acknowledges delivery; ordinary 4xx, including 429, are not retried within the run. Attempts reuse the same id, signature, and raw bytes. A later run may send again.
The sender keeps a seven-day successful-delivery receipt. That does not replace receiver deduplication: a crash after your 2xx but before receipt persistence can cause another delivery. Atomically record the signed body id (or organization plus id) with your durable enqueue/side effect. Return 2xx for already accepted ids. Do not mark an id handled before its work is durably accepted. Queue work and respond promptly rather than doing long-running actions in the request handler. Choose receiver retention for your replay window; do not rely on sender receipt expiry for correctness.