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.
Credential Binding
Section titled “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.
- Create a credential set:
wh credential create webhook-keys- Set authentication keys:
# Bearer tokenecho "tok_secret" | wh credential set webhook-keys WEBHOOK_BEARER_TOKEN
# Or API keyecho "key_secret" | wh credential set webhook-keys WEBHOOK_API_KEY- Bind the credential set to a subscription:
wh sub bind signal-hook --credentials webhook-keysSupported Auth Methods
Section titled “Supported Auth Methods”| Key Name | Header Produced |
|---|---|
WEBHOOK_BEARER_TOKEN | Authorization: Bearer <value> |
WEBHOOK_API_KEY | X-API-Key: <value> (or custom header via WEBHOOK_API_KEY_HEADER) |
WEBHOOK_BASIC_USERNAME + WEBHOOK_BASIC_PASSWORD | Authorization: Basic <base64> |
WEBHOOK_SIGNING_SECRET | X-WarmHub-Signature (HMAC-SHA256) + X-WarmHub-Timestamp — see Verifying Signatures |
FALLBACK_BEARER_TOKEN | Authorization: Bearer <value> on fallbackWebhookUrl deliveries |
FALLBACK_API_KEY | X-API-Key: <value> on fallbackWebhookUrl deliveries (or custom header via FALLBACK_API_KEY_HEADER) |
FALLBACK_BASIC_USERNAME + FALLBACK_BASIC_PASSWORD | Authorization: Basic <base64> on fallbackWebhookUrl deliveries |
FALLBACK_SIGNING_SECRET | X-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.
Unbinding
Section titled “Unbinding”wh sub unbind signal-hookRemoving the credential binding stops auth headers from being injected on future deliveries.
Verifying Signatures
Section titled “Verifying Signatures”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:
| Header | Value |
|---|---|
X-WarmHub-Signature | sha256=<hex> — the HMAC-SHA256 of the signed message, hex-encoded, with a literal sha256= prefix |
X-WarmHub-Timestamp | The Unix timestamp (seconds) used in the signed message |
What is signed
Section titled “What is signed”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.
Verify the signature
Section titled “Verify the signature”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 deliveryimport { 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 deliveryimport hashlibimport hmacimport 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:
# TIMESTAMP = the X-WarmHub-Timestamp header; body.json = the raw request bodyprintf '%s.%s' "$TIMESTAMP" "$(cat body.json)" \ | openssl dgst -sha256 -hmac "$WEBHOOK_SIGNING_SECRET" -hex# prepend "sha256=" to the output and compare to X-WarmHub-SignatureRotating the signing secret
Section titled “Rotating the signing secret”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:
- Update your receiver to verify against either the old or the new secret.
- Set the new value:
echo "new_secret" | wh credential set webhook-keys WEBHOOK_SIGNING_SECRET. - Once you’ve confirmed deliveries verify against the new secret, drop the old one from your receiver.
Hit a problem or have a question? Get in touch.