# CircleCI Webhooks

CircleCI (circleci.com) is a CI/CD platform. Its outbound webhooks POST JSON
to your endpoint when a workflow or a job reaches a terminal state.

> Not [Circle](/webhooks/skills/circle-webhooks).
> Circle (circle.com) is the USDC / Circle Payments Network / Circle Mint payments
> company. It signs with ECDSA and an `X-Circle-Signature` header. CircleCI
> signs with HMAC-SHA256 and a `circleci-signature` header. Two unrelated
> companies, two entirely different schemes — never mix their headers or events.

> Not CircleCI custom webhooks. Those go the opposite direction: a third
> party POSTs to CircleCI to trigger a pipeline
> ([docs](https://circleci.com/docs/guides/orchestrate/custom-webhooks/)). This
> skill is about CircleCI POSTing to you.

> The signing secret is not your CircleCI API token. `Circle-Token`
> authenticates you calling CircleCI's API. The webhook "Secret token"
> (API field `signing-secret`) verifies CircleCI calling you.

## When to Use This Skill

* How do I receive CircleCI webhooks?
* How do I verify a CircleCI webhook signature?
* Why is my `circleci-signature` verification failing?
* How do I handle `workflow-completed` or `job-completed` events?
* How do I get the branch and commit SHA out of a CircleCI webhook payload?
* How do I deduplicate CircleCI webhook deliveries?
* Does CircleCI send a challenge/handshake when I add a webhook endpoint?

## Verification (core)

CircleCI signs the raw request body bytes only — no timestamp, no delivery id,
no prefix, no delimiter — with HMAC-SHA256 keyed on the webhook's Secret token
used as UTF-8 bytes directly (no base64 decode). The digest is lowercase
hex (64 chars) and arrives in `circleci-signature` as a comma-separated list
of `<version>=<signature>` pairs. CircleCI's docs: "Only check the latest
signature type to prevent downgrade attacks." Today `v1` is the latest and only
version, so take the `v1` entry, ignore everything else, and reject if there is
no `v1` entry — never fall back to another version.

```javascript
const crypto = require('crypto');

function verifyCircleCISignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;               // fail closed

  // Comma-separated `<version>=<signature>`; split each pair on the FIRST `=`.
  let v1 = null;
  for (const pair of String(signatureHeader).split(',')) {
    const eq = pair.indexOf('=');
    if (eq === -1) continue;                                   // malformed pair
    if (pair.slice(0, eq).trim() === 'v1') { v1 = pair.slice(eq + 1).trim(); break; }
  }
  if (!v1) return false;    // no v1 entry -> reject; do NOT try v2/v3

  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(v1, 'utf8'), b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);  // length guard first
}

```

```python
import hmac, hashlib

def verify_circleci_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header or not secret:          # fail closed
        return False
    v1 = None
    for pair in signature_header.split(","):
        version, sep, sig = pair.strip().partition("=")   # split on the FIRST '='
        if sep and version.strip() == "v1":
            v1 = sig.strip()
            break
    if not v1:                                      # no v1 entry -> reject
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    # compare BYTES: str input raises TypeError on a non-ASCII (hostile) header
    return hmac.compare_digest(v1.encode("utf-8"), expected.encode("utf-8"))

```

> For complete handlers with tests, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/circleci-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/circleci-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/circleci-webhooks/examples/fastapi/).

### No SDK exists

CircleCI publishes no SDK helper for webhook verification. The docs give a
plain `hmac.new(bytes(secret, 'utf-8'), bytes(body, 'utf-8'), 'sha256').hexdigest()`
Python sample and nothing more. Manual HMAC is the only path — do not install an
`npm`/`pip` package that claims to do this.

### Known-answer test vectors

Straight from CircleCI's validation guide, all verified locally:

| Body | Secret | `v1` signature |
| --- | --- | --- |
| `hello world` | `secret` | `734cc62f32841568f45715aeb9f4d7891324e6d948e4c6c60c0621cdac48623a` |
| `lalala` | `another-secret` | `daa220016c8f29a8b214fbfc3671aeec2145cfb1e6790184ffb38b6d0425fa00` |
| `an-important-request-payload` | `hunter123` | `9be2242094a9a8c00c64306f382a7f9d691de910b4a266f67bd314ef18ac49fa` |
| `foo` | `secret` | `773ba44693c7553d6ee20f61ea5d2757a9a4f4a44d2841ae4e95b52e4cd62db4` |

## Gotchas That Actually Bite

There is no timestamp and no replay window. CircleCI's scheme signs the body
alone. There is no `circleci-timestamp` header and no documented tolerance — do
not invent one. `happened_at` in the body is event time, not a signing input;
rejecting on it will drop legitimate retries. Replay protection is
deduplication on the payload `id`.

Split each pair on the FIRST `=`. A naive `split('=')` on `v1=abc=def` loses
data. Base64 padding isn't a concern here (the digest is hex), but the versioned
list format is explicitly open-ended, so parse it properly.

Reject when `v1` is absent — never downgrade. `v2`/`v3` don't exist yet and
their algorithm is unknown. Verifying a `v2` value with SHA-256, or accepting it
because it "looks right", is exactly the downgrade attack the docs warn about.

Use the raw body. `express.json()` ahead of the route, `request.json()`
before `request.text()`, or re-serializing with `JSON.stringify` will all change
the bytes and break the digest.

The secret is used as UTF-8 bytes, not base64-decoded. It's whatever string
you typed into the "Secret token" field — no prefix to strip, no decoding.

`crypto.timingSafeEqual` throws on length mismatch. Guard the lengths first
(or `try`/`catch`), otherwise a truncated signature becomes a 500 that CircleCI
retries.

The secret token is optional in the UI. The web form marks it "Secret token: N"
(optional), and the headers table says the signature is sent "When present" —
so a misconfigured webhook can arrive unsigned. The examples here require
`CIRCLECI_WEBHOOK_SECRET` and fail closed (500 if unset, reject if the header
is missing). Never silently accept an unsigned request.

## No Handshake, No IP Allowlist

There is no challenge/echo/validation request. CircleCI doesn't ask your
endpoint to prove itself before sending.

The UI's "Test Ping Event" button sends an ordinary, fully-signed POST — the
docs say only that "The test ping event has an abbreviated payload for ease of
testing". The ping's exact `type` value is not documented. A community
implementation ([circleci-hook](https://github.com/DavidS/circleci-hook)) logged
a real one as `circleci-event-type: ping` with:

```json
{"type":"ping","id":"92e0554a-837f-4086-913b-0dc7665d2a84",
 "happened_at":"2022-09-19T15:59:36.507435Z",
 "webhook":{"id":"d4ab06bc-eb79-463d-8aa4-47d066382d3b","name":"fly.io"}}

```

Treat that shape as community-observed, not documented. Handle `ping`
gracefully (verify it like anything else, then just 200) and don't build logic
on its fields.

No source-IP allowlist is documented. Don't invent one; the HMAC is the
credential.

## Event Types

Exactly two. The API's `events` enum is `["workflow-completed", "job-completed"]`.

| Event | Fires when | `status` values |
| --- | --- | --- |
| `workflow-completed` | A workflow reached a terminal state | `success`, `failed`, `error`, `canceled`, `unauthorized` |
| `job-completed` | A job reached a terminal state | `success`, `failed`, `canceled`, `unauthorized` (no `error`) |

Do not invent others — there is no `workflow-started`, `job-started`, or any
`pipeline-*` event.

## Headers

| Header | Value |
| --- | --- |
| `content-type` | `application/json` |
| `user-agent` | `CircleCI-Webhook/1.0` |
| `circleci-event-type` | The event type, e.g. `workflow-completed` |
| `circleci-signature` | `v1=<hex>` (comma-separated versioned list), sent when a secret is configured |

There is no delivery-id header and no timestamp header. Dedupe on the
payload's top-level `id`.

## Envelope

```json
{
  "id": "3888f21b-eaa7-38e3-8f3d-75a63bba8895",
  "type": "workflow-completed",
  "happened_at": "2021-09-01T22:49:34.317Z",
  "webhook": { "id": "cf8c4fdd-0587-4da1-b4ca-4846e9640af9", "name": "Sample Webhook" },
  "project": { "id": "8499...", "name": "webhook-service", "slug": "github/circleci/webhook-service" },
  "organization": { "id": "f22b...", "name": "circleci" },
  "workflow": { "id": "fda0...", "name": "build-test-deploy", "created_at": "...",
                "stopped_at": "...", "url": "https://app.circleci.com/pipelines/...",
                "status": "success" },
  "pipeline": { "id": "1285...", "number": 130, "created_at": "...",
                "trigger": { "type": "webhook" }, "vcs": { "branch": "main", "revision": "1dc6aa6...",
                "commit": { "subject": "...", "author": { "name": "...", "email": "..." } } } }
}

```

`job-completed` additionally carries a `job` object
(`{id, number, name, status, started_at, stopped_at?}`), and its `workflow` has
no `status` — workflow status is only in workflow-level webhooks.

`pipeline.vcs` is not always there. It is present for GitHub OAuth and
Bitbucket Cloud pipelines. GitLab and GitHub App pipelines carry
`pipeline.trigger_parameters` (`{circleci, git, gitlab}`) instead and have no
`vcs`. Any handler reading branch or commit SHA must handle both shapes — see
`extractVcsInfo()` in the examples.

Payloads are "open maps." CircleCI: "New fields may be added to maps in the
webhook payload without considering it a breaking change." Parse leniently;
never assert on an exact key set.

## Delivery and Retries

* Answer 2xx within 10 seconds (the current timeout). Verify, enqueue,
  respond — do the work afterwards.
* A non-2xx response or a timeout is retried "at a later time". The retry
  count and schedule are not documented — don't build around a specific one.
* "Webhook requests may be duplicated" — dedupe on the payload `id`.
* Max 5 outbound webhooks per project. Webhooks are per-project and require
  an org admin to manage.

## Environment Variables

```bash
# The "Secret token" from Project Settings -> Webhooks (API field: signing-secret).
# Used as UTF-8 bytes directly — no prefix to strip, no base64 decoding.
# This is NOT your CircleCI API token (Circle-Token).
CIRCLECI_WEBHOOK_SECRET=your_webhook_secret_token

```

## Local Development

```bash
npx hookdeck-cli listen 3000 circleci --path /webhooks/circleci

```

No account required — the CLI creates a guest account on first run and gives you
a public HTTPS URL plus a web UI for inspecting requests. Paste the printed URL
into Project Settings → Webhooks → Add Webhook, set the same Secret token as
`CIRCLECI_WEBHOOK_SECRET`, and hit Test Ping Event.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/circleci-webhooks/references/overview.md) — Both event types, the full envelope, `vcs` vs `trigger_parameters`, deduplication, retries, limits
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/circleci-webhooks/references/setup.md) — Configuring webhooks in Project Settings and via the v2 API, the Secret token, Test Ping Event
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/circleci-webhooks/references/verification.md) — The signature scheme byte by byte, test vectors, downgrade attacks, debugging failures