Skip to main content
Every webhook notification sent by Circle is signed with an asymmetric key. By verifying the signature on each notification, you confirm the payload came from Circle and wasn’t tampered with in transit. The verification flow is the same across Circle products. Only the public key endpoint differs per product.

Signature scheme

Circle signs each webhook notification with the ECDSA_SHA_256 algorithm. Every notification includes two headers your endpoint uses to verify the signature:
  • X-Circle-Signature: the digital signature of the notification body, base64-encoded.
  • X-Circle-Key-Id: the UUID of the public key that signed the notification.
Each signature is unique to the notification it accompanies, so run the verification flow below for every webhook you receive.

Verify a signature

1

Read the signature and key ID from the headers

Extract X-Circle-Signature and X-Circle-Key-Id from the incoming webhook request’s headers.
X-Circle-Key-Id: 879dc113-5ca4-4ff7-a6b7-54652083fcf8
X-Circle-Signature: MEYCIQCA9EvPbdEJiy7Cw0eY+KQZA/oFi5ZEInPs8CYpyaJexgIhAKtRNnDz9QRQmFKx8QFrvawp+8b9Bs2dQ03xD+XaWVDE
2

Fetch the public key

Using the value of X-Circle-Key-Id, call your product’s public key endpoint to retrieve the public key and algorithm. Replace <public_key_endpoint> with the endpoint for your product:
curl --request GET \
  --url 'https://api.circle.com/<public_key_endpoint>/879dc113-5ca4-4ff7-a6b7-54652083fcf8' \
  --header 'Authorization: Bearer $CIRCLE_API_KEY'
A successful response returns the base64-encoded public key:
{
  "data": {
    "id": "879dc113-5ca4-4ff7-a6b7-54652083fcf8",
    "algorithm": "ECDSA_SHA_256",
    "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESl76SZPBJemW0mJNN4KTvYkLT8bOT4UGhFhzNk3fJqf6iuPlLQLq533FelXwczJbjg2U1PHTvQTK7qOQnDL2Tg==",
    "createDate": "2026-01-15T21:47:35.107250Z"
  }
}
The public key for a given keyId is static, so cache the result to avoid fetching it on every webhook.
3

Verify the signature against the raw body

Use the public key to verify the signature against the raw request body. Parsing the JSON and re-serializing it changes the byte order, so the signature no longer matches.
import { createVerify, createPublicKey, KeyObject } from "crypto";

// Cache the public key by keyId to avoid refetching on every webhook.
const publicKeyCache = new Map<string, KeyObject>();

async function getPublicKey(keyId: string): Promise<KeyObject> {
  const cached = publicKeyCache.get(keyId);
  if (cached) return cached;

  // Replace <public_key_endpoint> with your product's endpoint.
  const response = await fetch(
    `https://api.circle.com/<public_key_endpoint>/${keyId}`,
    { headers: { Authorization: `Bearer ${process.env.CIRCLE_API_KEY}` } },
  );
  const { data } = await response.json();

  const publicKey = createPublicKey({
    key: Buffer.from(data.publicKey, "base64"),
    format: "der",
    type: "spki",
  });
  publicKeyCache.set(keyId, publicKey);
  return publicKey;
}

export async function verifyWebhook(
  rawBody: string,
  signature: string,
  keyId: string,
): Promise<boolean> {
  const publicKey = await getPublicKey(keyId);
  const verifier = createVerify("SHA256");
  verifier.update(rawBody);
  return verifier.verify(publicKey, signature, "base64");
}
If verification succeeds, the notification is authentic. If it fails, reject the request.