Skip to main content

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.

Headers on signed deliveries

When signing is enabled, every outgoing webhook includes:
HeaderValue
Sixtyfour-Signaturet=<unix_seconds>,v1=<hex>[,v1=<hex>]
Sixtyfour-Event-IdUUID per delivery — use as your dedupe key
Sixtyfour-Event-TypeRouting hint (e.g., find_email, outgoing_webhook.completed)
Sixtyfour-Delivery-AttemptAttempt counter starting at 1; increments on each retry

Verifying signatures

The signature is HMAC_SHA256(secret_utf8_bytes, f"{t}.{raw_body}"). To verify:
  1. Parse t= (timestamp) and every v1= (signature) from the Sixtyfour-Signature header.
  2. Reject if |now - t| > 300 (5-minute clock-skew tolerance).
  3. Recompute the HMAC against the raw bytes you received.
  4. 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.