Use Case
Every outgoing webhook from Sixtyfour can be signed with HMAC-SHA256 so you can verify it actually came from us. One signing secret per organization covers all outgoing webhook traffic: async-job webhooks (receive results) and the workflow outgoing_webhook block.
Until you create a signing secret, deliveries are sent unsigned for backward compatibility. Once you create one, all subsequent deliveries are signed.
Generate a signing secret
Generate a signing secret in the dashboard at Settings → API Keys → Webhooks.
The plaintext secret (sk_whsec_<64 hex>) is shown only once at creation — store it immediately in a secret manager. Afterwards only a masked preview (sk_whsec_••••…<last4>) is displayed.
When signing is enabled, every outgoing webhook includes:
| Header | Value |
|---|
Sixtyfour-Signature | t=<unix_seconds>,v1=<hex>[,v1=<hex>] |
Sixtyfour-Event-Id | UUID per delivery — use as your dedupe key |
Sixtyfour-Event-Type | Routing hint (e.g., find_email, outgoing_webhook.completed) |
Sixtyfour-Delivery-Attempt | Attempt counter starting at 1; increments on each retry |
Verifying signatures
The signature is HMAC_SHA256(secret_utf8_bytes, f"{t}.{raw_body}"). To verify:
- Parse
t= (timestamp) and every v1= (signature) from the Sixtyfour-Signature header.
- Reject if
|now - t| > 300 (5-minute clock-skew tolerance).
- Recompute the HMAC against the raw bytes you received.
- Accept if any
v1= matches.
Verify against the raw request body, before JSON parsing. Re-serializing a parsed dict changes the bytes (whitespace, key order) and verification will fail.
During rotation the header carries two v1= segments (new, then old). Iterate every segment — do not parse the header into a dict, which drops duplicates.
Example Webhook Verification
import hmac, hashlib, time
from flask import Flask, request, abort
SECRET = "sk_whsec_..." # store in a secret manager
TOLERANCE = 300
app = Flask(__name__)
def verify(raw: bytes, header: str, secret: str) -> bool:
parts = [p.strip() for p in header.split(",")]
try:
t = int(next(p.split("=", 1)[1] for p in parts if p.startswith("t=")))
except (StopIteration, ValueError):
return False
if abs(int(time.time()) - t) > TOLERANCE:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{t}.".encode("utf-8") + raw,
hashlib.sha256,
).hexdigest()
return any(
p.startswith("v1=") and hmac.compare_digest(expected, p.split("=", 1)[1])
for p in parts
)
@app.post("/webhooks/sixtyfour")
def handle():
raw = request.get_data() # raw bytes, before JSON parse
sig = request.headers.get("Sixtyfour-Signature", "")
if not verify(raw, sig, SECRET):
abort(400)
event = request.get_json()
return ("", 200)
Rotation
Rotate from the dashboard. The default overlap window is 24 hours: during the window we dual-sign every delivery with both old and new secrets so you can deploy your new secret without dropping events. Set the overlap to 0 for an immediate cut-over (e.g. suspected compromise).
Two rotation rules to remember in your verification code:
- The
Sixtyfour-Signature header carries multiple v1= segments during overlap. You should iterate every segment — accept the delivery if any one matches. See the code examples above for reference.
- You should not parse the header into a dictionary keyed by name — that silently drops the second
v1=.
Fail-closed behavior
If signing is configured but we cannot load your secret at delivery time we abort delivery rather than silently downgrade to unsigned. Once you opt into signing, every payload is signed or none are.
If deliveries unexpectedly stop after enabling signing, results remain retrievable through the polling fallbacks:
Troubleshooting
For signature verification failures and signing-related delivery issues, see Handling Errors.