Agent skill

Circle Webhooks Skill

Receive and verify Circle Payments Network (CPN) v2 webhooks. Use when setting up Circle webhook handlers, debugging ECDSA signature verification (X-Circle-Signature, X-Circle-Key-Id), or handling notifications like cpn.payment.*, cpn.transaction.*, and cpn.rfi.*.

Install this skill

npx skills add hookdeck/webhook-skills --skill circle-webhooks


When to Use This Skill

  • How do I receive Circle webhooks?
  • How do I verify Circle webhook signatures (ECDSA / X-Circle-Signature)?
  • How do I fetch and cache the Circle notification public key by X-Circle-Key-Id?
  • How do I handle cpn.payment.*, cpn.transaction.*, or cpn.rfi.* notifications?
  • Why is my Circle webhook signature verification failing?

How Circle Webhooks Differ From Most Providers

Circle's v2 notifications are signed with an asymmetric ECDSA key — not HMAC, and not the Standard Webhooks spec. Each POST carries two headers:

HeaderPurpose
X-Circle-SignatureBase64-encoded ECDSA (ECDSA_SHA_256) signature of the raw body
X-Circle-Key-IdUUID of the public key that signed the notification

You verify by fetching the matching public key from Circle's API (GET /v2/cpn/notifications/publicKey/{keyId}, returns a base64 DER/SPKI key), then verifying the signature over the raw request body with ECDSA-SHA256. The public key for a keyId is static — cache it by keyId to avoid an API call per event.

Two more Circle specifics:

  • HEAD validation. On subscription create/update Circle validates your endpoint with a HEAD request (no subscribe-URL handshake). Return 200 to HEAD as well as POST.
  • Product scope. This skill covers Circle Payments Network (CPN) v2 notifications, which use a notificationType body field carrying cpn.* event strings (cpn.payment.completed, cpn.transaction.broadcasted, cpn.rfi.approved, …). Circle Mint / Core API (v1) is a separate product with a different notification scheme — this skill does not cover it.

Verification (core)

Circle has no webhook-verify SDK helper, so verify manually. Node.js:

const { createPublicKey, createVerify } = require('crypto');
const publicKeyCache = new Map(); // keyId -> KeyObject (public keys are static)

async function getPublicKey(keyId) {
  if (publicKeyCache.has(keyId)) return publicKeyCache.get(keyId);
  const res = await fetch(
    `${process.env.CIRCLE_API_BASE_URL}/v2/cpn/notifications/publicKey/${keyId}`,
    { headers: { Authorization: `Bearer ${process.env.CIRCLE_API_KEY}` } }
  );
  const { data } = await res.json();
  const key = createPublicKey({
    key: Buffer.from(data.publicKey, 'base64'), // base64 DER (SPKI)
    format: 'der',
    type: 'spki',
  });
  publicKeyCache.set(keyId, key);
  return key;
}

async function verifyCircleWebhook(headers, rawBody) {
  const signature = headers['x-circle-signature'];
  const keyId = headers['x-circle-key-id'];
  if (!signature || !keyId) return false;
  const publicKey = await getPublicKey(keyId).catch(() => null);
  if (!publicKey) return false;
  const verifier = createVerify('SHA256');
  verifier.update(rawBody); // raw bytes, not parsed JSON
  verifier.end();
  try {
    return verifier.verify(publicKey, signature, 'base64');
  } catch {
    return false;
  }
}

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Common Event Types

CPN identifies each event by the notificationType field in the body (not a header) — a cpn.* string. The changed resource is carried in the notification object, whose shape matches the corresponding API response (the lifecycle status is notification.status). Configure which types you receive via a subscription's notificationTypes (wildcards like cpn.payment.* and * are supported).

notificationTypeDescription
cpn.payment.completedA CPN payment reached the completed state
cpn.payment.failedA CPN payment failed
cpn.payment.delayedA CPN payment is delayed
cpn.transaction.broadcastedAn onchain transaction was broadcast
cpn.transaction.completedAn onchain transaction completed
cpn.transaction.failedAn onchain transaction failed
cpn.rfi.approvedA request-for-information (RFI) was approved
cpn.rfi.rejectedA request-for-information (RFI) was rejected

Wildcards: cpn.payment.*, cpn.transaction.*, cpn.rfi.* (the RFI family also includes an information-needed variant), or * for every type. See references/overview.md for status values and payloads.

Environment Variables

CIRCLE_API_KEY=your_circle_api_key_here        # fetches the notification public key
CIRCLE_API_BASE_URL=https://api.circle.com     # sandbox: https://api-sandbox.circle.com

Local Development

For local webhook testing, run the Hookdeck CLI via npx — no install required:

npx hookdeck-cli listen 3000 circle --path /webhooks/circle

Then create a notification subscription (API or console) pointing endpoint at the printed forwarding URL. No account required — the CLI creates a guest account on first run and gives you a tunnel + web UI for inspecting requests.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 3, 2026

View on GitHub →