Webhook signature verification¶
Astrolift's generic outbound-webhook envelope uses HMAC-SHA256. This guide defines the receiver contract and gives an independently reproducible test vector.
Known control-plane defect
The current control plane returns an alfthk_... plaintext secret only at
subscription creation/rotation but persists only its SHA-256 digest. The
dashboard's Send test path then uses that stored digest as the HMAC key.
A receiver configured with the returned plaintext cannot verify that test
delivery. The asynchronous outbound-delivery workflow is also not wired to
an HTTP sender in the current code.
Treat outbound application webhooks as unavailable until the installed release fixes key storage/signing and includes an end-to-end delivery test. Do not work around this by configuring a receiver with a database digest.
This limitation does not describe inbound GitHub/GitLab source webhooks or inbound workflow triggers; those have separate provider/trigger authentication contracts.
Signing contract¶
For a conforming delivery Astrolift:
- serializes the JSON payload once and retains those exact bytes;
- records a positive Unix timestamp in seconds;
- constructs
ASCII(timestamp) + b"." + raw_body; - computes
HMAC-SHA256(secret, signing_input); and - sends
sha256=<lowercase-hex-digest>inX-Astrolift-Signature.
The receiver must verify the raw body before parsing or reserializing it.
Headers¶
| Header | Meaning |
|---|---|
X-Astrolift-Event-Type |
Event kind, for example deployment.succeeded |
X-Astrolift-Event-Id |
Stable idempotency key for the event |
X-Astrolift-Delivery-Id |
ID for this delivery/attempt |
X-Astrolift-Signature |
sha256=<hex> over timestamp + "." + raw_body |
X-Astrolift-Timestamp |
Unix timestamp used by the signature |
X-Astrolift-Schema |
Opaque payload-schema version such as 1 or 1.0 |
Content-Type |
application/json for the generic format |
User-Agent |
astrolift-platform/1 by default |
Do not parse X-Astrolift-Schema as a semantic version unless the event's
schema documentation explicitly requires that.
Verification rules¶
Your receiver should:
- read the raw request bytes once;
- reject a missing, malformed, or non-integer timestamp;
- reject timestamps outside a five-minute freshness window;
- calculate the expected signature over the exact raw bytes;
- use a constant-time comparison; and
- atomically deduplicate the delivery or event ID before applying side effects.
Return any 2xx for success. The delivery classifier treats 410 Gone as an
immediate request to disable the subscription, other 4xx responses as
permanent failures, and 5xx/transport errors as retryable. Policy code defines
an initial attempt plus up to eight jittered retries, but production users
should verify the delivery workflow is wired in their installed release.
Correct test vector¶
secret UTF-8 bytes: key
secret hex: 6b6579
timestamp: 1700000000
raw body: {"kind":"deployment.succeeded","app":"my-app"}
signature: sha256=1256659fed14420fe226e54f69fa207ab64e286e1c755d63bf637038224c7162
Verify it independently:
printf '%s' '1700000000.{"kind":"deployment.succeeded","app":"my-app"}' \
| openssl dgst -sha256 -hmac 'key' -hex
OpenSSL prints the digest with its own label. Compare the hexadecimal value,
then prefix it with sha256= for the header.
Python¶
import hashlib
import hmac
import time
def verify_astrolift_webhook(
*,
secret: str,
raw_body: bytes,
signature_header: str,
timestamp_header: str,
now_unix: int | None = None,
freshness_seconds: int = 300,
) -> bool:
if not signature_header.startswith("sha256="):
return False
try:
timestamp = int(timestamp_header)
except (TypeError, ValueError):
return False
now = int(time.time()) if now_unix is None else now_unix
if abs(now - timestamp) > freshness_seconds:
return False
message = f"{timestamp}.".encode("ascii") + raw_body
expected = "sha256=" + hmac.new(
secret.encode("utf-8"), message, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
For Flask, call request.get_data() before request.get_json() and pass the
returned bytes to the function.
TypeScript / Node.js¶
import crypto from "node:crypto";
export function verifyAstroliftWebhook(options: {
secret: string;
rawBody: Buffer;
signatureHeader: string;
timestampHeader: string;
nowUnix?: number;
freshnessSeconds?: number;
}): boolean {
const {
secret,
rawBody,
signatureHeader,
timestampHeader,
nowUnix = Math.floor(Date.now() / 1000),
freshnessSeconds = 300,
} = options;
if (!signatureHeader.startsWith("sha256=")) return false;
if (!/^[0-9]+$/.test(timestampHeader)) return false;
const timestamp = Number(timestampHeader);
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(nowUnix - timestamp) > freshnessSeconds) return false;
const message = Buffer.concat([
Buffer.from(`${timestamp}.`, "ascii"),
rawBody,
]);
const expected = `sha256=${crypto
.createHmac("sha256", secret)
.update(message)
.digest("hex")}`;
const left = Buffer.from(expected, "ascii");
const right = Buffer.from(signatureHeader, "ascii");
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
With Express, use express.raw({ type: "application/json" }) on the webhook
route. Normalize header values safely:
const signature = String(req.headers["x-astrolift-signature"] ?? "");
const timestamp = String(req.headers["x-astrolift-timestamp"] ?? "");
Go¶
package astroliftwebhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func Verify(
secret, rawBody []byte,
signatureHeader, timestampHeader string,
now time.Time,
) bool {
if !strings.HasPrefix(signatureHeader, "sha256=") {
return false
}
timestamp, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil || timestamp <= 0 {
return false
}
if delta := now.Unix() - timestamp; delta > 300 || delta < -300 {
return false
}
message := append([]byte(fmt.Sprintf("%d.", timestamp)), rawBody...)
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write(message)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
Read r.Body with io.ReadAll before decoding JSON. If downstream middleware
must read it again, restore it with io.NopCloser(bytes.NewReader(rawBody)).
Secret rotation¶
The intended rotation contract returns a new alfthk_ plaintext exactly once
and retains one previous key for a grace period. The default grace period is one
hour and is capped at 24 hours. A receiver should try the current plaintext key,
then the previous plaintext key only during that window.
The known storage/signing defect above means the current control plane cannot honor that receiver contract correctly. Rotation is not a substitute for fixing the signing-key model.
Testing¶
There is no astro webhook test-delivery CLI command in the current release.
The dashboard has a Send test event action and records the attempt in
delivery history, but its signature is affected by the known key defect. After
that defect is fixed, use the dashboard action to exercise the exact delivery
path, and include tests that reject:
- a changed byte in the body;
- a changed timestamp;
- a stale timestamp;
- a signature created with the wrong key; and
- a duplicate delivery ID.