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;
}
Verifying Multipart Signaturesโ
When a webhook uses binary_multipart file delivery, the signature cannot be computed over the raw request body โ the multipart/form-data boundary is randomized on each request. Instead, epilot signs a deterministic content string derived from the delivered files.
The signed body is built per file as:
<sha256_hex_of_file_bytes>.<canonical_metadata_json>
where canonical_metadata_json is the file's metadata serialized with its keys in alphabetical order:
{"entity_id":"...","filename":"...","mime_type":"...","size_bytes":204800,"version_index":0}
When multiple files are sent, each per-file segment is joined with a newline (\n) in the order the files appear in the request. This joined string is the request_body used in the signed content format above โ everything else (the webhook-id.webhook-timestamp. prefix, the v1a/v1 signatures) works exactly as for JSON webhooks.
import crypto from "node:crypto";
// `fileBuffer` is the raw bytes of the received file part.
// filename / mime_type are read from the part's Content-Disposition and
// Content-Type headers; size_bytes is the part's byte length. entity_id and
// version_index are not in the part โ deliver them yourself via `extraFields`.
function multipartSignedBody(
fileBuffer: Buffer,
metadata: {
entity_id: string;
filename: string;
mime_type: string;
size_bytes: number;
version_index: number;
}
): string {
const sha256 = crypto.createHash("sha256").update(fileBuffer).digest("hex");
// Keys MUST be serialized in alphabetical order
const canonicalMeta = JSON.stringify(metadata, Object.keys(metadata).sort());
return `${sha256}.${canonicalMeta}`;
}
Verify this reconstructed string with the same v1/v1a logic shown above. Note that extraFields scalar parts are not included in the signed content.
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.