Skip to content

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:

  1. Records a Unix timestamp (seconds since epoch) at delivery time.
  2. Constructs the signing input: "<timestamp>.<raw_body>" — the timestamp as a decimal ASCII string, a literal period, then the raw request body bytes.
  3. Computes HMAC-SHA256(secret, signing_input).
  4. Sets the result as sha256=<hex_digest> in the X-Astrolift-Signature header.

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:

  1. 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.
  2. Read X-Astrolift-Timestamp and X-Astrolift-Signature.
  3. Reconstruct the signing input: f"{timestamp}.".encode() + raw_body.
  4. Compute HMAC-SHA256(secret, signing_input) and format as sha256=<hex>.
  5. Compare your computed value against X-Astrolift-Signature using a constant-time comparison to prevent timing attacks.
  6. 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:

  1. Generates a new alfthk_-prefixed secret and returns the plaintext to you once.
  2. Keeps the old secret valid for one hour (the default grace window, configurable up to 24 hours).
  3. 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:

astro webhook test-delivery \
  --subscription <subscription-id> \
  --event-type deployment.succeeded

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.