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 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:

  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 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:

  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 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.