Skip to content

Webhook signatures

Every request to an Alfe webhook must carry a signature made with that webhook’s secret. Alfe checks it before anything reaches your agent, and it rejects a missing or invalid signature with a 401.

Alfe expects this:

  • Header — X-Alfe-Signature-256.
  • Algorithm — HMAC-SHA256.
  • Key — the webhook’s signing secret, exactly as Alfe returned it.
  • Message — the raw request body, as the exact bytes you send.
  • Format — sha256= followed by the lowercase hex digest.
X-Alfe-Signature-256: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

Sign the exact bytes you send. If you sign an object and then serialize it again, whitespace or key order can change, and the signature won’t match.

These examples sign a JSON body and send it to a webhook.

Terminal
BODY='{"event":"deploy.finished","service":"api"}'
SIGNATURE="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | awk '{print $NF}')"
curl "https://webhooks.alfe.ai/v1/hooks/$WEBHOOK_ID" \
-H "Content-Type: application/json" \
-H "X-Alfe-Signature-256: $SIGNATURE" \
-d "$BODY"
send-event.ts
import crypto from "node:crypto";
const body = JSON.stringify({ event: "deploy.finished", service: "api" });
const signature =
"sha256=" +
crypto
.createHmac("sha256", process.env.SIGNING_SECRET!)
.update(body)
.digest("hex");
await fetch(process.env.WEBHOOK_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Alfe-Signature-256": signature,
},
body,
});
send_event.py
import hashlib
import hmac
import json
import os
import urllib.request
body = json.dumps({"event": "deploy.finished", "service": "api"}).encode()
signature = "sha256=" + hmac.new(
os.environ["SIGNING_SECRET"].encode(), body, hashlib.sha256
).hexdigest()
request = urllib.request.Request(
os.environ["WEBHOOK_URL"],
data=body,
headers={
"Content-Type": "application/json",
"X-Alfe-Signature-256": signature,
},
)
urllib.request.urlopen(request)

Receive events from GitHub, Stripe, or Slack

Section titled “Receive events from GitHub, Stripe, or Slack”

These services sign with their own headers, and you can’t change that. So they can’t call an Alfe webhook URL directly. Put a small relay between them:

  1. Point the service’s webhook at your relay.
  2. In the relay, verify the service’s own signature, using its documented scheme.
  3. Sign the same raw body with your Alfe secret.
  4. Forward the body to the webhook URL, and pass along any delivery ID header, so Alfe can spot duplicates.
relay.ts
import crypto from "node:crypto";
import express from "express";
const app = express();
// Keep the raw bytes, so both signatures cover exactly what was sent.
app.use(express.raw({ type: "*/*" }));
app.post("/github", async (req, res) => {
// Verify GitHub's X-Hub-Signature-256 here, and return 401 if it fails.
// Re-sign the same bytes for Alfe.
const signature =
"sha256=" +
crypto
.createHmac("sha256", process.env.ALFE_SIGNING_SECRET!)
.update(req.body)
.digest("hex");
const response = await fetch(process.env.ALFE_WEBHOOK_URL!, {
method: "POST",
headers: {
"Content-Type": req.header("content-type") ?? "application/json",
"X-Alfe-Signature-256": signature,
"X-GitHub-Delivery": req.header("x-github-delivery") ?? "",
"X-GitHub-Event": req.header("x-github-event") ?? "",
},
body: req.body,
});
res.sendStatus(response.ok ? 200 : 502);
});

Alfe passes Content-Type, X-GitHub-Event, X-GitHub-Delivery, and Stripe-Signature through to your agent with the payload. It drops other headers.

When you rotate a webhook’s secret, the old one stops working right away. Update your sender as part of the rotation. See Rotate the signing secret.