Webhook Signature Verification¶
Every outbound webhook Astrolift delivers is signed with HMAC-SHA256. This guide explains the signing scheme, how to verify signatures in your receiver, and how to avoid common mistakes.
How signing works¶
When Astrolift delivers an event to your endpoint it:
- Records a Unix timestamp (seconds since epoch) at delivery time.
- Constructs the signing input:
"<timestamp>.<raw_body>"— the timestamp as a decimal ASCII string, a literal period, then the raw request body bytes. - Computes
HMAC-SHA256(secret, signing_input). - Sets the result as
sha256=<hex_digest>in theX-Astrolift-Signatureheader.
The timestamp is also sent separately in X-Astrolift-Timestamp so your receiver can enforce a freshness window without parsing it out of the signing input.
Request headers¶
Every delivery carries this header envelope:
| Header | Description |
|---|---|
X-Astrolift-Event-Type |
Event kind (e.g. deployment.succeeded) |
X-Astrolift-Event-Id |
Idempotency key for the event |
X-Astrolift-Delivery-Id |
Unique ID for this delivery attempt |
X-Astrolift-Signature |
sha256=<hex> — HMAC-SHA256 of "<timestamp>.<body>" |
X-Astrolift-Timestamp |
Unix timestamp (decimal seconds) used in the signature |
X-Astrolift-Schema |
Schema version of the payload (e.g. v1) |
Content-Type |
Always application/json |
User-Agent |
astrolift-platform/1 |
Verification steps¶
Your receiver must:
- Read the raw request body before parsing it as JSON. The signature is over the raw bytes; parsing and re-serialising will change whitespace and fail.
- Read
X-Astrolift-TimestampandX-Astrolift-Signature. - Reconstruct the signing input:
f"{timestamp}.".encode() + raw_body. - Compute
HMAC-SHA256(secret, signing_input)and format assha256=<hex>. - Compare your computed value against
X-Astrolift-Signatureusing a constant-time comparison to prevent timing attacks. - Check that
abs(now - timestamp) <= 300(five-minute freshness window). Reject deliveries outside the window to block replay attacks.
Return 200 (or any 2xx) on success. Return 410 Gone to permanently unsubscribe this endpoint — Astrolift will disable the subscription immediately.
Test vectors¶
Use these to verify your implementation before wiring it to a live endpoint.
secret (hex bytes): 6b6579
timestamp: 1700000000
body: {"kind":"deployment.succeeded","app":"my-app"}
signing_input (hex):
31373030303030303030 2e 7b226b696e64223a226465706c6f796d656e742e73756363656564656422...
(i.e. b"1700000000." + body bytes)
expected X-Astrolift-Signature:
sha256=b27d265c35c3c7f86c35cfebfe6e19fbdb754a13d8f4c91e7c8bf70b3a6d1b49
Quick verification from the shell:
# Using the values above
echo -n '1700000000.{"kind":"deployment.succeeded","app":"my-app"}' \
| openssl dgst -sha256 -hmac 'key' -hex
# stdout: sha256=b27d265c35c3c7f86c35cfebfe6e19fbdb754a13d8f4c91e7c8bf70b3a6d1b49
Code examples¶
import hashlib
import hmac
import time
from typing import Optional
FRESHNESS_WINDOW = 300 # seconds
def verify_astrolift_webhook(
*,
secret: str,
raw_body: bytes,
signature_header: str,
timestamp_header: str,
now: Optional[int] = None,
) -> bool:
"""Return True only when signature and timestamp are both valid."""
if not signature_header or not signature_header.startswith("sha256="):
return False
try:
timestamp = int(timestamp_header)
except (ValueError, TypeError):
return False
now = now if now is not None else int(time.time())
if abs(now - timestamp) > FRESHNESS_WINDOW:
return False
signing_input = f"{timestamp}.".encode("ascii") + raw_body
expected = "sha256=" + hmac.new(
secret.encode("utf-8"),
signing_input,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)
# Flask example
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "alfthk_your_secret_here"
@app.route("/webhook", methods=["POST"])
def receive():
raw_body = request.get_data() # read raw bytes before any parsing
ok = verify_astrolift_webhook(
secret=WEBHOOK_SECRET,
raw_body=raw_body,
signature_header=request.headers.get("X-Astrolift-Signature", ""),
timestamp_header=request.headers.get("X-Astrolift-Timestamp", ""),
)
if not ok:
abort(401)
payload = request.get_json()
# handle payload...
return "", 200
import crypto from "crypto";
const FRESHNESS_WINDOW_SECONDS = 300;
function verifyAstroliftWebhook(options: {
secret: string;
rawBody: Buffer;
signatureHeader: string;
timestampHeader: string;
nowUnix?: number;
}): boolean {
const { secret, rawBody, signatureHeader, timestampHeader } = options;
if (!signatureHeader?.startsWith("sha256=")) return false;
const timestamp = parseInt(timestampHeader, 10);
if (isNaN(timestamp)) return false;
const now = options.nowUnix ?? Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > FRESHNESS_WINDOW_SECONDS) return false;
const signingInput = Buffer.concat([
Buffer.from(`${timestamp}.`, "ascii"),
rawBody,
]);
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(signingInput)
.digest("hex");
// timingSafeEqual requires equal-length buffers
const expectedBuf = Buffer.from(expected);
const presentedBuf = Buffer.from(signatureHeader);
if (expectedBuf.length !== presentedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, presentedBuf);
}
// Express example
import express, { Request, Response } from "express";
const app = express();
const WEBHOOK_SECRET = "alfthk_your_secret_here";
app.post(
"/webhook",
express.raw({ type: "application/json" }), // raw body required
(req: Request, res: Response) => {
const ok = verifyAstroliftWebhook({
secret: WEBHOOK_SECRET,
rawBody: req.body as Buffer,
signatureHeader: req.headers["x-astrolift-signature"] as string ?? "",
timestampHeader: req.headers["x-astrolift-timestamp"] as string ?? "",
});
if (!ok) return res.status(401).end();
const payload = JSON.parse((req.body as Buffer).toString("utf-8"));
// handle payload...
res.status(200).end();
}
);
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)
const freshnessWindow = 300 // seconds
// Verify returns true only when signature and freshness both pass.
func Verify(secret, rawBody []byte, signatureHeader, timestampHeader string) bool {
if !strings.HasPrefix(signatureHeader, "sha256=") {
return false
}
ts, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil {
return false
}
now := time.Now().Unix()
if math.Abs(float64(now-ts)) > freshnessWindow {
return false
}
prefix := []byte(fmt.Sprintf("%d.", ts))
signingInput := append(prefix, rawBody...)
mac := hmac.New(sha256.New, secret)
mac.Write(signingInput)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
// Handler is an http.Handler that verifies the signature before calling next.
func Handler(secret []byte, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
if !Verify(
secret, body,
r.Header.Get("X-Astrolift-Signature"),
r.Header.Get("X-Astrolift-Timestamp"),
) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
Common pitfalls¶
Reading the body twice. Web frameworks often buffer the body, but some stream it. If you parse JSON first and then try to read raw bytes you will get an empty buffer. Always read raw bytes first, then parse.
Encoding the secret as UTF-8 vs raw bytes. Secrets beginning with alfthk_ are printable ASCII; encoding them as UTF-8 and as ASCII produces identical bytes. If you store the secret encoded in some other form (hex, base64), decode it to raw bytes before passing it to HMAC.
String comparison instead of constant-time comparison. Regular string equality short-circuits on the first mismatched byte, leaking timing information. Always use hmac.compare_digest (Python), crypto.timingSafeEqual (Node.js), or hmac.Equal (Go).
Comparing with padding whitespace. If you trim or strip the X-Astrolift-Signature header value before comparing, make sure you do the same to your computed value. A mismatch of even one byte fails the constant-time check.
Ignoring the freshness window. Skipping the timestamp check means a captured delivery can be replayed indefinitely. Enforce the five-minute window (or tighter). The X-Astrolift-Delivery-Id header is an idempotency key — record processed delivery IDs to deduplicate retries within the window.
Secret rotation. When you rotate a webhook secret in the dashboard, the previous secret remains valid for a one-hour grace window. During that window Astrolift may sign with either secret. If you store the previous secret you can verify against both during rotation rollout.
Secret rotation¶
When you rotate a webhook secret Astrolift:
- Generates a new
alfthk_-prefixed secret and returns the plaintext to you once. - Keeps the old secret valid for one hour (the default grace window, configurable up to 24 hours).
- Signs new deliveries with the new secret. Deliveries already in-flight during the grace window may carry a signature from either secret.
During the grace window your receiver should try the new secret first, fall back to the previous secret on mismatch, and reject if neither matches.
Testing with the CLI¶
Once astro webhook test-delivery is implemented you will be able to send a synthetic event to your endpoint:
Until then you can use curl to generate a test delivery manually, using the test vector above to confirm your verification logic before wiring it up.