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.*, orcpn.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:
| Header | Purpose |
|---|---|
X-Circle-Signature | Base64-encoded ECDSA (ECDSA_SHA_256) signature of the raw body |
X-Circle-Key-Id | UUID 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
HEADrequest (no subscribe-URL handshake). Return200toHEADas well asPOST. - Product scope. This skill covers Circle Payments Network (CPN) v2 notifications, which use a
notificationTypebody field carryingcpn.*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).
notificationType | Description |
|---|---|
cpn.payment.completed | A CPN payment reached the completed state |
cpn.payment.failed | A CPN payment failed |
cpn.payment.delayed | A CPN payment is delayed |
cpn.transaction.broadcasted | An onchain transaction was broadcast |
cpn.transaction.completed | An onchain transaction completed |
cpn.transaction.failed | An onchain transaction failed |
cpn.rfi.approved | A request-for-information (RFI) was approved |
cpn.rfi.rejected | A 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
- references/overview.md — Circle webhook concepts, notification types, status values, payloads
- references/setup.md — Create subscriptions (API/console), get the public key, sandbox vs production, egress IPs
- references/verification.md — ECDSA verification (Node + Python), gotchas, debugging