Guide to Circle Webhooks: Features and Best Practices
Circle Payments Network (CPN) webhooks notify your systems about payment activity: a payment completes, a transaction settles, a request-for-information or refund is raised. If you're building on CPN, webhooks are how you react to payment lifecycle events without polling.
This guide covers how CPN webhooks work, the events you'll handle, how to verify the ECDSA signature (this is asymmetric crypto, not an HMAC), the endpoint validation, and the best practices for production.
This guide covers Circle Payments Network (CPN), whose events are
cpn.*. Circle's separate W3S / Programmable Wallets product uses different events (and a different endpoint). Confirm which Circle product your integration targets before wiring up event handling.
What are Circle webhooks?
CPN "v2" webhooks are HTTP POSTs delivered to a URL you subscribe, each signed with an asymmetric ECDSA key. Each delivery carries an X-Circle-Signature (a base64 signature) and an X-Circle-Key-Id (a public-key UUID). You fetch the matching public key from Circle's API and verify the signature over the raw body.
Circle webhook features
| Feature | Details |
|---|---|
| Product | Circle Payments Network (CPN) |
| Signature header | X-Circle-Signature (base64) |
| Key ID header | X-Circle-Key-Id (public key UUID) |
| Signature scheme | ECDSA (ECDSA_SHA_256) over the raw body |
| Public key | GET /v2/cpn/notifications/publicKey/{keyId} (cache by keyId) |
| Endpoint validation | v2: HEAD request; legacy v1: SNS SubscriptionConfirmation |
| Acknowledgement | Return 200 (non-200 triggers retries) |
| IP allowlist | 35.169.154.32, 3.90.127.28, 3.230.111.7, 54.88.227.75 |
| SDK | Wallets SDKs exist; no CPN-webhook-verification helper (DIY crypto) |
Common events
CPN events use a cpn. prefix:
| Event | Fires when |
|---|---|
cpn.payment.completed | A payment completes |
cpn.transaction.completed | A transaction settles |
cpn.rfi.* | A request-for-information event is raised |
cpn.refund.* | A refund event occurs |
webhooks.test | A test event you can trigger during setup |
Subscribe to the events you process, and consult Circle's CPN webhook reference for the full list.
Setting up Circle webhooks
Configure subscriptions via the CPN API, or (for Circle Mint customers) in the console at app.circle.com under Developer > Subscriptions. On subscription create/update, CPN v2 validates your endpoint with a HEAD request (there's no SubscribeURL handshake); your endpoint must handle HEAD and POST over public HTTPS and return 200. (The legacy v1 SNS path instead sends a one-time SubscriptionConfirmation with a SubscribeURL.) Optionally allowlist CPN's egress IPs.
Securing Circle webhooks
Each delivery carries X-Circle-Signature (base64) and X-Circle-Key-Id. To verify, fetch the public key for that keyId from GET /v2/cpn/notifications/publicKey/{keyId} (which returns algorithm ECDSA_SHA_256 and a base64 key), then verify the signature over the raw request body using ECDSA with SHA-256. Cache the key by keyId so you don't fetch it on every event.
const crypto = require("crypto");
const keyCache = new Map(); // keyId -> KeyObject
async function getPublicKey(keyId) {
if (keyCache.has(keyId)) return keyCache.get(keyId);
const res = await fetch(
`https://api.circle.com/v2/cpn/notifications/publicKey/${keyId}`,
{ headers: { Authorization: `Bearer ${process.env.CIRCLE_API_KEY}` } }
).then((r) => r.json());
// res.data.publicKey is base64 (SPKI DER); algorithm "ECDSA_SHA_256"
const key = crypto.createPublicKey({
key: Buffer.from(res.data.publicKey, "base64"),
format: "der",
type: "spki",
});
keyCache.set(keyId, key);
return key;
}
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const keyId = req.headers["x-circle-key-id"];
const signature = Buffer.from(req.headers["x-circle-signature"] || "", "base64");
const publicKey = await getPublicKey(keyId);
// ECDSA over the raw body with SHA-256
if (!crypto.verify("sha256", req.body, publicKey, signature)) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge fast
processQueue.add(JSON.parse(req.body)); // process asynchronously
});
// v2 endpoint validation
app.head("/webhook", (req, res) => res.sendStatus(200));
Confirm the exact key encoding (SPKI DER vs PEM) and ECDSA signature encoding against Circle's current CPN docs when you implement this; there's no official CPN-webhook-verification SDK, so verification is DIY with a crypto library.
Circle webhook limitations and pain points
ECDSA with a fetched public key, not an HMAC
The Problem: Verification is asymmetric: fetch a public key by keyId, then verify an ECDSA signature over the raw body. There's no shared secret and no HMAC, so the usual HMAC reflex doesn't apply.
Why It Happens: CPN v2 uses asymmetric ECDSA signing with rotating, ID-referenced public keys.
Workarounds:
- Fetch the key by
X-Circle-Key-Id, cache it, and verify with ECDSA/SHA-256 over the raw body.
How Hookdeck Can Help: Hookdeck verifies the ECDSA signature at the edge, including the key fetch and caching, so your application receives pre-verified events without embedding asymmetric crypto in your handler.
HEAD endpoint validation
The Problem: v2 validates your endpoint with a HEAD request. An endpoint that only implements POST fails validation and the subscription doesn't activate.
Why It Happens: CPN v2 uses a HEAD check rather than a payload handshake.
Workarounds:
- Implement a HEAD handler that returns 200 alongside your POST handler.
How Hookdeck Can Help: Hookdeck answers the HEAD validation and forwards verified events to your endpoint, so subscription setup isn't a manual concern.
Which Circle product
The Problem: CPN events are cpn.*; the separate W3S / Programmable Wallets product uses different events and a different endpoint. Building against the wrong product means your event handling never matches.
Why It Happens: Circle ships multiple products under one brand with distinct webhook models.
Workarounds:
- Confirm your integration targets CPN (this guide) versus W3S before listing events.
How Hookdeck Can Help: Hookdeck can ingest either product and normalize events, so downstream consumers aren't coupled to which Circle product sent them.
Retries and duplicates
The Problem: Non-200 responses trigger retries, so the same payment event can arrive more than once. Double-processing a payment is costly.
Why It Happens: At-least-once delivery favors eventual delivery of payment events.
Workarounds:
- Dedupe on the event ID and make payment side effects idempotent; reconcile against the API for high-value flows.
How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge, so retries don't double-process payments. See our guide to webhook idempotency.
Best practices
Verify ECDSA over the raw body
Fetch the public key by X-Circle-Key-Id, cache it, and verify the X-Circle-Signature over the raw body with ECDSA/SHA-256.
Handle the HEAD validation
Return 200 to the v2 HEAD request so subscriptions activate.
Acknowledge fast, process asynchronously
Return 200 immediately and defer work to a queue. See why to process webhooks asynchronously.
Dedupe and confirm before settling
Dedupe on the event ID, make side effects idempotent, and confirm payment state via the API before releasing value.
Make Circle webhooks production-ready
Hookdeck verifies the ECDSA signature, answers the HEAD check, deduplicates, and durably queues every payment event
Conclusion
Circle Payments Network webhooks are signed with ECDSA, verified against a public key you fetch by X-Circle-Key-Id over the raw body, with a v2 HEAD endpoint validation and cpn.* events. The details that matter are the asymmetric verification (not an HMAC), caching the public key, handling the HEAD check, and confirming you're on CPN rather than W3S.
Hookdeck verifies the ECDSA signature, answers the HEAD check, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique payment events.
Get started with Hookdeck for free and handle Circle webhooks reliably in minutes.