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 behavior depends on how the credential set is lost:

  • Revoked credential set: Fail-closed. WarmHub blocks the delivery before any outbound request is made — to either the primary or fallback URL. A transient resolution failure is treated as a retryable error. WarmHub will never send an authenticated or signed delivery without the configured credentials in place.
  • Deleted credential set: WarmHub treats the subscription as having no binding. Future deliveries proceed without injecting auth headers and are sent unsigned and unauthenticated. If you need deliveries to stop entirely when credentials are removed, revoke the credential set instead of deleting it — revocation is fail-closed.
  • Unbound credential set: WarmHub treats the subscription as having no binding. Future deliveries proceed without injecting auth headers — they are sent unsigned and unauthenticated. See Unbinding below.
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.