Webhook delivery
How Audact signs, retries, and dead-letters outbound webhook deliveries. Covers the HMAC-SHA256 signature scheme, rotation grace period, retry schedule, and resend API.
This documents the webhook delivery mechanism: signing, retry, and dead-letter behaviour. Every outbound webhook delivery carries the following headers and follows the retry/dead-letter schedule below.
Signature scheme
Every delivery is signed with HMAC-SHA256 and carries four headers:
| Header | Meaning |
|---|---|
X-Audact-Signature | t=<unix_seconds>,v1=<hex_hmac> (see below for the dual-v1= rotation form) |
X-Audact-Timestamp | The same <unix_seconds> value, as its own header |
X-Audact-Event | The event type (e.g. call_started, call_summary) |
X-Audact-Delivery-Id | A per-attempt UUID; unique on every retry attempt of the same event |
Do not confuse this with the tool-dispatch header. Outbound tool calls (agent → tenant tool endpoint) are signed with a different, unrelated header,
X-Signature— see tool-dispatch. Searching forX-Signaturein this spec will not find the webhook scheme.
What is signed. The exact string "<unix_timestamp>.<raw_request_body>" — the timestamp, a
literal ., then the raw JSON body bytes exactly as sent (not a re-serialization). The MAC is
HMAC-SHA256, encoded as lowercase hex.
Single-key header value:
t=1750000000,v1=5f4dcc3b5aa765d61d8327deb882cf99...
Dual-signature rotation grace (72 hours). POST /v1/webhooks/secrets/rotate retains the
previous signing key as valid for 72 hours after rotation. During that window, every delivery
carries two v1= values in the same header — one HMAC under the old key, one under the new key:
t=1750000000,v1=<hmac_under_old_key>,v1=<hmac_under_new_key>
A verifier should accept the signature if any v1= value matches (this is exactly what
the server-side verifier does — it iterates all v1= values and constant-time-compares each). This means a
receiver only needs to store its currently-known secret and try it against every v1= value; it
does not need to know which one is "current" vs "old". GET /v1/webhooks/secrets/status reports
whether a rotation is in progress and the grace_period_ends timestamp.
Replay protection. The signature header does not itself expire — the verifier is responsible
for rejecting stale timestamps. Use a tolerance check with a recommended maximum age of 300 seconds (5 minutes) between the t= value and
"now". Comparison of the HMAC digest must be constant-time; do the
same on the receiving end to avoid timing side-channels.
Obtaining the signing secret (reveal-once)
The per-tenant signing secret is generated server-side and stored only in a secrets manager — it is never retrievable after the fact, matching the Stripe/Twilio pattern. It is returned in the JSON response body, in plaintext, exactly once, from two calls:
POST /v1/webhooks— when this call provisions the tenant's signing secret for the first time (i.e. the tenant had no webhook endpoint, and therefore no secret, before this call), the response includessigning_secretalongside the created endpoint. Any subsequentPOST /v1/webhookscall for the same tenant reuses the already-provisioned secret and omitssigning_secretfrom its response.POST /v1/webhooks/secrets/rotate— every successful rotation returns the newly generated key assigning_secretin its response body. This is the only way to see a signing secret again after the first reveal — rotate, and copy the new value down immediately.
No other endpoint ever returns the secret. In particular, GET /v1/webhooks/secrets/status only
ever reports rotation metadata (in_rotation, grace_period_ends, created_at) — never the key
material itself. Store the revealed value securely (e.g. your own secrets manager); if it is lost,
the only recovery path is POST /v1/webhooks/secrets/rotate, which invalidates the old key after
the 72-hour dual-signature grace period described above.
Copy-paste verifier (Go)
go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func verifyAudactWebhook(secret []byte, sigHeader string, rawBody []byte) bool {
var ts int64
var sigs []string
for _, part := range strings.Split(sigHeader, ",") {
if strings.HasPrefix(part, "t=") {
ts, _ = strconv.ParseInt(strings.TrimPrefix(part, "t="), 10, 64)
} else if strings.HasPrefix(part, "v1=") {
sigs = append(sigs, strings.TrimPrefix(part, "v1="))
}
}
age := time.Now().Unix() - ts
if age < 0 {
age = -age
}
if age > 300 {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(fmt.Sprintf("%d.%s", ts, rawBody)))
expected := hex.EncodeToString(mac.Sum(nil))
for _, sig := range sigs {
if hmac.Equal([]byte(sig), []byte(expected)) {
return true
}
}
return false
}Reimplementing without the Python SDK helper: parse t= and every v1= value from the header, recompute
hex(hmac_sha256(secret, fmt.Sprintf("%d.%s", ts, rawBody))), and accept if it constant-time-matches
any v1= value and abs(now - ts) <= 300s.
Destination rules (SSRF guard)
Webhook receivers must be public HTTPS endpoints.
- Rejected at create/update (400,
details.reason = "blocked_destination"): IP literals in loopback, private (RFC 1918), link-local (including the cloud metadata address169.254.169.254), CGNAT, documentation, benchmarking, reserved, multicast and NAT64 ranges, and their IPv6 equivalents; the loopback hostname; single-label host names (container service names such asredis); names ending in.internal,.local,.localdomain,.home.arpa,.lanor.corp; and names that resolve to any non-public address. - Enforced on every delivery, resend and test ping: the address is re-checked at connection time,
after hostname resolution. This also covers webhooks created before this guard existed.
Such deliveries fail with
blocked_destination. - Redirects are not followed. A 3xx response counts as a non-2xx failure. Point the webhook at the final URL.
- Error reporting: a network-level failure is reported (in
test_webhookand in the delivery log'slast_error) as one ofblocked_destination,timeout,tls_error,dns_errororconnection_failed. The underlying network error is never returned.
Retry schedule
- Maximum 4 total attempts (1 initial delivery + 3 retries). This ceiling is currently fixed, not configurable per tenant.
- Base delays between attempts:
1s,10s,60s— applied before attempts 2, 3, and 4 respectively. - Jitter: each base delay is multiplied by a random factor in
[0.7, 1.3](±30%), so actual delays are roughly0.7–1.3s,7–13s,42–78s. - Per-attempt HTTP timeout: 10 seconds.
- Any non-2xx response or network/timeout error triggers a retry (subject to the ceiling above).
Dead-letter and resend
- After all 4 attempts are exhausted, the delivery is written to a dead-letter store with the original payload preserved verbatim.
- Retention: 90 days from the time the entry is dead-lettered.
- Recover a dead-lettered delivery with
POST /v1/webhooks/deliveries/{id}/resend— this re-queues the original event onto the normal dispatch path (fresh delivery attempts, freshX-Audact-Delivery-Idvalues). - List dead-lettered deliveries for a tenant via the dead-letter listing endpoint to build monitoring/alerting.
Practical implications
- Treat delivery as at-least-once: a receiver may see the same
X-Audact-Delivery-Idmore than once only if it retries its own processing after a 2xx was already returned to Audact and Audact's own retry logic re-fires (e.g. a network blip after your 2xx was sent but before Audact received it) — design handlers to be idempotent onX-Audact-Delivery-Idregardless. - If your endpoint is down for longer than the full retry window (~90 seconds) plus the time it takes you to notice, the delivery lands in the dead-letter store; monitor it and call the resend endpoint once your endpoint is healthy again rather than relying on Audact to retry indefinitely.
