Skip to content

Subscription Credentials & Signatures

Webhook deliveries can carry authentication headers, and WarmHub can sign each delivery so your receiver can verify it came from WarmHub and wasn’t tampered with. Both are configured with credential binding.

Webhook subscriptions can use credential binding to inject authentication headers into delivery requests. This keeps secrets out of webhook URLs and subscription configuration.

  1. Create a credential set:
Terminal window
wh credential create webhook-keys
  1. Set authentication keys:
Terminal window
# Bearer token
echo "tok_secret" | wh credential set webhook-keys WEBHOOK_BEARER_TOKEN
# Or API key
echo "key_secret" | wh credential set webhook-keys WEBHOOK_API_KEY
  1. Bind the credential set to a subscription:
Terminal window
wh sub bind signal-hook --credentials webhook-keys
Key NameHeader Produced
WEBHOOK_BEARER_TOKENAuthorization: Bearer <value>
WEBHOOK_API_KEYX-API-Key: <value> (or custom header via WEBHOOK_API_KEY_HEADER)
WEBHOOK_BASIC_USERNAME + WEBHOOK_BASIC_PASSWORDAuthorization: Basic <base64>
WEBHOOK_SIGNING_SECRETX-WarmHub-Signature (HMAC-SHA256) + X-WarmHub-Timestamp — see Verifying Signatures
FALLBACK_BEARER_TOKENAuthorization: Bearer <value> on fallbackWebhookUrl deliveries
FALLBACK_API_KEYX-API-Key: <value> on fallbackWebhookUrl deliveries (or custom header via FALLBACK_API_KEY_HEADER)
FALLBACK_BASIC_USERNAME + FALLBACK_BASIC_PASSWORDAuthorization: Basic <base64> on fallbackWebhookUrl deliveries
FALLBACK_SIGNING_SECRETX-WarmHub-Signature (HMAC-SHA256) + X-WarmHub-Timestamp on fallbackWebhookUrl deliveries

Credential resolution is best-effort — if a credential set is missing or revoked, the webhook is still delivered without auth headers.

Terminal window
wh sub unbind signal-hook

Removing the credential binding stops auth headers from being injected on future deliveries.

When a subscription binds a WEBHOOK_SIGNING_SECRET, WarmHub signs every delivery so your receiver can confirm the request came from WarmHub and the body wasn’t modified in transit. Each delivery carries two headers:

HeaderValue
X-WarmHub-Signaturesha256=<hex> — the HMAC-SHA256 of the signed message, hex-encoded, with a literal sha256= prefix
X-WarmHub-TimestampThe Unix timestamp (seconds) used in the signed message

The signed message is the timestamp and the raw request body, joined by a literal period:

<X-WarmHub-Timestamp>.<raw request body>

WarmHub computes HMAC-SHA256(secret, message) and sends the hex digest as X-WarmHub-Signature: sha256=<hex>. To verify, recompute the HMAC over the exact bytes you received — read the raw body before any JSON parsing or re-serialization, which would change the bytes and break the match.

Always (1) recompute the HMAC over timestamp + "." + rawBody, (2) compare it to the header with a constant-time comparison, and (3) reject deliveries whose timestamp is outside a freshness window you choose, to limit replay. WarmHub does not enforce a replay window — that check is yours.

// TypeScript (Node) — verify a WarmHub webhook delivery
import { createHmac, timingSafeEqual } from 'node:crypto'
function verifyWarmHubDelivery(
rawBody: string,
signatureHeader: string | undefined, // X-WarmHub-Signature
timestampHeader: string | undefined, // X-WarmHub-Timestamp
secret: string,
): boolean {
if (!signatureHeader || !timestampHeader) return false
// Replay window — reject deliveries older than 5 minutes (your choice).
const age = Math.floor(Date.now() / 1000) - Number(timestampHeader)
if (!Number.isFinite(age) || Math.abs(age) > 300) return false
const expected =
'sha256=' +
createHmac('sha256', secret)
.update(`${timestampHeader}.${rawBody}`)
.digest('hex')
const got = Buffer.from(signatureHeader)
const want = Buffer.from(expected)
return got.length === want.length && timingSafeEqual(got, want)
}
# Python — verify a WarmHub webhook delivery
import hashlib
import hmac
import time
def verify_warmhub_delivery(raw_body: bytes, signature: str | None,
timestamp: str | None, secret: str) -> bool:
if not signature or not timestamp:
return False
# Replay window — reject deliveries older than 5 minutes (your choice).
try:
age = int(time.time()) - int(timestamp)
except ValueError:
return False
if abs(age) > 300:
return False
message = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(
secret.encode(), message, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)

To check a captured delivery by hand, recompute the digest with openssl and compare it to the X-WarmHub-Signature header:

Terminal window
# TIMESTAMP = the X-WarmHub-Timestamp header; body.json = the raw request body
printf '%s.%s' "$TIMESTAMP" "$(cat body.json)" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SIGNING_SECRET" -hex
# prepend "sha256=" to the output and compare to X-WarmHub-Signature

WEBHOOK_SIGNING_SECRET is a single value per credential set, and WarmHub signs with whatever is bound at delivery time — there is no dual-secret grace window on WarmHub’s side. To rotate without dropping deliveries, make your receiver accept the new secret before you rotate:

  1. Update your receiver to verify against either the old or the new secret.
  2. Set the new value: echo "new_secret" | wh credential set webhook-keys WEBHOOK_SIGNING_SECRET.
  3. Once you’ve confirmed deliveries verify against the new secret, drop the old one from your receiver.