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
from createWebhookSubscription and rotateOutboundWebhookSecret
(plaintextSecret, once) but persists only its SHA-256 digest. Both the
dashboard test (testWebhookSubscription) and the asynchronous
DeliverWebhookWorkflow then use that stored digest string as the HMAC
key. A receiver configured with the returned plaintext cannot verify any
delivery.
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.
Agent completion callbacks use organization
secret references, the v1= signature contract and X-Astrolift-Event /
X-Astrolift-Delivery headers, with their own durable retry and sensitive-body
handling. Do not apply this generic sha256= contract to an
agent_task.finished completion callback.
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 |
Currently the same value as X-Astrolift-Delivery-Id; not stable across retries |
X-Astrolift-Delivery-Id |
URL-safe random ID for this delivery attempt; a retry carries a new one |
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. The current sender sets it to
1.
A subscription's format is generic (the raw Astrolift envelope, kind plus
event fields), slack or discord; the signature covers the shaped body that
was sent, whichever format is selected. Subscriptions are managed with
createWebhookSubscription, updateWebhookSubscription,
deleteWebhookSubscription, testWebhookSubscription,
rotateOutboundWebhookSecret and the paged astroliftWebhookSubscriptionsPage
query; the installation advertises webhooks.subscribe and
webhooks.secret_rotation in astroliftServerInfo.capabilities.
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 before applying side effects. Because both ID headers change on every retry today, deduplicate on identifiers inside the payload (the resource and request fields of the envelope) rather than on the headers alone.
Return any 2xx for success. The delivery classifier treats 410 Gone as an
immediate request to disable the subscription (it is marked inactive with a
disabled_at time, not deleted), other 4xx responses as permanent failures,
and 5xx/transport errors as retryable. Each attempt times out after 10
seconds. DeliverWebhookWorkflow makes an initial attempt plus up to eight
retries at 5 s, 30 s, 2 m, 10 m, 30 m, 2 h, 6 h and 24 h, each jittered by
20 percent; fifty consecutive failures auto-disable the subscription.
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
Expected output ends in
1256659fed14420fe226e54f69fa207ab64e286e1c755d63bf637038224c7162. OpenSSL
prints the digest with its own label. Compare the hexadecimal value, then
prefix it with sha256= for the header. For a real delivery substitute the
X-Astrolift-Timestamp value, the exact request bytes and the alfthk_
plaintext from the creation or rotation response; a mismatch after
re-serializing the JSON (key order, whitespace, Unicode escapes) is the usual
cause, not a bad key.
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. Check it against the test vector:
assert verify_astrolift_webhook(
secret="key",
raw_body=b'{"kind":"deployment.succeeded","app":"my-app"}',
signature_header="sha256=1256659fed14420fe226e54f69fa207ab64e286e1c755d63bf637038224c7162",
timestamp_header="1700000000",
now_unix=1700000000,
)
False with a correct key and body almost always means raw_body was read
after a JSON parser consumed and re-encoded it, or now_unix is more than 300
seconds from the timestamp (clock skew on the receiver).
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¶
rotateOutboundWebhookSecret returns a new alfthk_ plaintext exactly once
and retains one previous key for a grace period. The default grace period is one
hour, operator-tunable between 60 seconds and 24 hours
(WEBHOOK_SECRET_ROTATION_GRACE_SECONDS); only one generation is kept, so a
second rotation inside the window retires the oldest key immediately. New
deliveries always sign with the current key. 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¶
The CLI has no webhook test-delivery command in the current release (astro org
webhooks is listed but has no subcommands). The dashboard's test action
(testWebhookSubscription) posts a synthetic webhook.test event through the
same post_webhook sender as real deliveries and records the attempt in
delivery history flagged as a test, 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.