Every webhook notification sent by StableFX is digitally signed with an
asymmetric key. This guide demonstrates how to use the key and signature to
verify that a webhook notification was sent by Circle. Validating webhooks in
this way can reduce the risk of person-in-the-middle attacks on your subscriber
endpoint.
Steps
Use the following steps to verify the Circle signature on a webhook
notification.
Step 1: Get the digital signature and ID of the notification
Every webhook notification is digitally signed with an asymmetric key. The
asymmetric key is random for each webhook, so you must perform this full
authentication flow to validate the key. This signature is available in the
header of the message. Each message contains the following headers:
X-Circle-Signature: the digital signature generated by Circle
X-Circle-Key-Id: the public key ID in UUID format
Extract those values from the header of the webhook message.
Step 2: Fetch the public key and verify the signature
Using the X-Circle-Key-Id value, query the
/v2/stablefx/notifications/publicKey/{id}
endpoint to retrieve the public key, then use it to verify the signature.
Ensure that you read the raw request body as a string for signature
verification. Parsing and re-serializing the JSON can change whitespace or key
ordering, causing verification to fail.
The following TypeScript code demonstrates a complete webhook verification flow:
import { createVerify, createPublicKey, KeyObject } from "crypto";
const CIRCLE_API_KEY = process.env.CIRCLE_API_KEY!;
// Cache for public keys to avoid repeated API calls
const publicKeyCache = new Map<string, KeyObject>();
async function getPublicKey(keyId: string): Promise<KeyObject> {
// Check cache first
const cachedKey = publicKeyCache.get(keyId);
if (cachedKey) {
return cachedKey;
}
// Fetch the public key from Circle's API
const response = await fetch(
`https://api.circle.com/v2/stablefx/notifications/publicKey/${keyId}`,
{
headers: {
Accept: "application/json",
Authorization: `Bearer ${CIRCLE_API_KEY}`,
},
},
);
if (!response.ok) {
throw new Error(`Failed to fetch public key: ${response.statusText}`);
}
const { data } = await response.json();
const publicKeyDer = Buffer.from(data.publicKey, "base64");
// Convert DER-encoded public key to a KeyObject
const publicKey = createPublicKey({
key: publicKeyDer,
format: "der",
type: "spki",
});
// Cache the key for future use
publicKeyCache.set(keyId, publicKey);
return publicKey;
}
function verifySignature(
publicKey: KeyObject,
signature: string,
payload: string,
): boolean {
const signatureBytes = Buffer.from(signature, "base64");
const verifier = createVerify("SHA256");
verifier.update(payload);
verifier.end();
return verifier.verify(publicKey, signatureBytes);
}
// Example webhook handler
async function handleWebhook(request: Request): Promise<Response> {
// Extract signature headers
const signature = request.headers.get("X-Circle-Signature");
const keyId = request.headers.get("X-Circle-Key-Id");
if (!signature || !keyId) {
return new Response("Missing signature headers", { status: 401 });
}
// Get the raw request body
const payload = await request.text();
try {
// Fetch the public key and verify the signature
const publicKey = await getPublicKey(keyId);
const isValid = verifySignature(publicKey, signature, payload);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
// Signature is valid - process the webhook
const webhookData = JSON.parse(payload);
console.log("Verified webhook:", webhookData.notificationType);
// Handle the webhook event...
return new Response("OK", { status: 200 });
} catch (error) {
console.error("Webhook verification failed:", error);
return new Response("Verification failed", { status: 401 });
}
}
The example includes a simple in-memory cache for public keys. In a production
environment, consider using a more robust caching solution like Redis.