Skip to main content
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:
TypeScript
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.