Security
caution
Always verify webhook signatures before processing payloads. Without verification, an attacker could send forged requests to your endpoint.
Endpoint Authenticationโ
epilot supports three methods for authenticating requests to your webhook endpoint:
API Keyโ
epilot sends your configured API key in the X-API-Key header with each request. Validate this key before processing any payload.
Basic Authโ
epilot Base64-encodes the configured username and password and sends them in the Authorization: Basic header. Always use HTTPS to prevent credential exposure.
OAuthโ
epilot obtains an access token from your authorization server and sends it in the Authorization: Bearer header. Token expiration and refresh are handled automatically.
Webhook Signature Verificationโ
Every webhook request from epilot includes three signature headers:
| Header | Description |
|---|---|
webhook-id | Unique message identifier (e.g. msg_2a4f8b...) |
webhook-timestamp | Unix timestamp (seconds) when the request was signed |
webhook-signature | Space-separated signatures: v1a,<asymmetric> v1,<symmetric> |
Two Signatures, Two Purposesโ
epilot sends two signatures with each webhook request:
v1a(asymmetric, Ed25519) โ Proves the request came from your organization. Verified using your organization's public key, which is specific to your tenant and never shared across organizations.v1(symmetric, HMAC-SHA256) โ Proves the request is intended for your specific webhook. Verified using thewhsec_...signing secret you received when the webhook was created.
Both signatures are computed over the same content:
signed_content = ${webhook-id}.${webhook-timestamp}.${request_body}
Verificationโ
Option 1: Symmetric Verification (recommended for most use cases)โ
Use the standardwebhooks npm package to verify the v1 signature with your webhook's signing secret.
import { Webhook } from "standardwebhooks";
const signingSecret = "whsec_..."; // from webhook creation response
function verifyWebhook(req: Request): boolean {
const payload = req.body; // raw request body as string
const headers = req.headers;
// Extract the v1 signature and convert prefix for standardwebhooks compatibility
const signatureHeader = headers["webhook-signature"];
const v1sSig = signatureHeader
.split(" ")[1]
if (!v1sSig) {
throw new Error("No symmetric signature found");
}
const wh = new Webhook(signingSecret);
// verify() throws if the signature is invalid or timestamp is too old
wh.verify(payload, {
"webhook-id": headers["webhook-id"],
"webhook-timestamp": headers["webhook-timestamp"],
"webhook-signature": v1sSig,
});
return true;
}
Option 2: Asymmetric Verificationโ
Use Node.js crypto to verify the v1a Ed25519 signature with your organization's public key. Each tenant has their own key pair.
import crypto from "node:crypto";
// Fetch your organization's public key (per-tenant)
// Requires org_id query parameter
async function getOrgPublicKey(orgId: string): Promise<string> {
const response = await fetch(
`https://webhooks.sls.epilot.io/v1/webhooks/.well-known/public-key?orgId=${orgId}`
);
const data = await response.json();
return data.public_key;
}
async function verifyAsymmetric(req: Request, orgId: string): Promise<boolean> {
const payload = req.body; // raw request body as string
const headers = req.headers;
// Fetch and cache your organization's public key (rarely changes)
// The public key is specific to your organization (org_id)
const orgPublicKey = await getOrgPublicKey(orgId);
const signatureHeader = headers["webhook-signature"];
const v1aSig = signatureHeader
.split(" ")
.find((s) => s.startsWith("v1a,"));
if (!v1aSig) {
throw new Error("No asymmetric signature found");
}
const signature = Buffer.from(v1aSig.replace("v1a,", ""), "base64");
const signedContent = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${payload}`;
return crypto.verify(
null,
new TextEncoder().encode(signedContent),
orgPublicKey,
signature
);
}
Option 3: Verify Bothโ
For maximum security, verify both signatures:
async function verifyWebhookFull(req: Request, orgId: string): Promise<boolean> {
// 1. Check timestamp freshness (reject requests older than 5 minutes)
const timestamp = Number(req.headers["webhook-timestamp"]);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error("Webhook timestamp too old โ possible replay attack");
}
// 2. Verify asymmetric signature (proves it came from your organization)
const isFromOrg = await verifyAsymmetric(req, orgId);
if (!isFromOrg) {
throw new Error("Invalid asymmetric signature");
}
// 3. Verify symmetric signature (proves it's for your webhook)
verifyWebhook(req); // throws on failure
return true;
}
Fetching the Public Keyโ
To fetch your organization's public key, include your organization ID as a query parameter:
curl "https://webhooks.sls.epilot.io/v1/webhooks/.well-known/public-key?orgId=YOUR_ORG_ID"
Response:
{
"public_key": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----\n"
}
Cache this key โ it's unique to your organization and rarely changes. The public key is derived from your organization's private Ed25519 key pair, which is stored encrypted and never leaves epilot's systems.
Signing Secretโ
caution
The whsec_... signing secret is returned only once in the response when you create a webhook. Store it securely. If lost, you'll need to recreate the webhook to get a new one.