Skip to main content
Audact
See the offer

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:

HeaderMeaning
X-Audact-Signaturet=<unix_seconds>,v1=<hex_hmac> (see below for the dual-v1= rotation form)
X-Audact-TimestampThe same <unix_seconds> value, as its own header
X-Audact-EventThe event type (e.g. call_started, call_summary)
X-Audact-Delivery-IdA 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 for X-Signature in 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 includes signing_secret alongside the created endpoint. Any subsequent POST /v1/webhooks call for the same tenant reuses the already-provisioned secret and omits signing_secret from its response.
  • POST /v1/webhooks/secrets/rotate — every successful rotation returns the newly generated key as signing_secret in 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 address 169.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 as redis); names ending in .internal, .local, .localdomain, .home.arpa, .lan or .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_webhook and in the delivery log's last_error) as one of blocked_destination, timeout, tls_error, dns_error or connection_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 roughly 0.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, fresh X-Audact-Delivery-Id values).
  • 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-Id more 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 on X-Audact-Delivery-Id regardless.
  • 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.

Source updated: October 19, 2026