# 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:

| 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 `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:

```javascript
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/circle-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/circle-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/circle-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/circle-webhooks/references/overview.md) for status values and payloads.

## Environment Variables

```bash
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:

```bash
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](https://github.com/hookdeck/webhook-skills/blob/main/skills/circle-webhooks/references/overview.md) — Circle webhook concepts, notification types, status values, payloads
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/circle-webhooks/references/setup.md) — Create subscriptions (API/console), get the public key, sandbox vs production, egress IPs
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/circle-webhooks/references/verification.md) — ECDSA verification (Node + Python), gotchas, debugging