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.

  1. PUT /api/archdev/org/webhook with JSON {"url":"https://example.com/hooks/archdev","enabled":false,"min_risk":"high","events":["plan","task","pr"]}. events selects event families, not individual lifecycle names.
  2. POST /api/archdev/org/webhook/secret/rotate. Copy the returned secret into 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.
  3. Install signature verification at the receiver, then PUT /api/archdev/org/webhook with the same settings and enabled:true. Saving without a secret leaves delivery disabled; rotating alone does not enable it.
  4. POST /api/archdev/org/webhook/test. The response reports delivered and status_code; confirm your receiver accepted the request. It sends a signed synthetic critical-risk event from the first subscribed family, with test:true and a unique webhook-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.