Skip to content

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:

  1. serializes the JSON payload once and retains those exact bytes;
  2. records a positive Unix timestamp in seconds;
  3. constructs ASCII(timestamp) + b"." + raw_body;
  4. computes HMAC-SHA256(secret, signing_input); and
  5. sends sha256=<lowercase-hex-digest> in X-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:

  1. read the raw request bytes once;
  2. reject a missing, malformed, or non-integer timestamp;
  3. reject timestamps outside a five-minute freshness window;
  4. calculate the expected signature over the exact raw bytes;
  5. use a constant-time comparison; and
  6. 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.