Agent skill

CircleCI Webhooks Skill

Receive and verify CircleCI outbound webhooks. Use when setting up a CircleCI webhook handler, debugging `circleci-signature` verification, or handling the `workflow-completed` and `job-completed` events CircleCI sends when a workflow or job reaches a terminal state. CircleCI signs the raw body with HMAC-SHA256 and sends a hex digest in a comma-separated versioned list (`v1=<hex>`) — only the latest version (`v1`) should ever be checked. Not Circle (circle.com, USDC/Circle Mint, ECDSA `X-Circle-Signature`) — unrelated company. Not CircleCI custom webhooks, which are inbound pipeline triggers going the other direction.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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. 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). 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.

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
}
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/, examples/nextjs/, 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:

BodySecretv1 signature
hello worldsecret734cc62f32841568f45715aeb9f4d7891324e6d948e4c6c60c0621cdac48623a
lalalaanother-secretdaa220016c8f29a8b214fbfc3671aeec2145cfb1e6790184ffb38b6d0425fa00
an-important-request-payloadhunter1239be2242094a9a8c00c64306f382a7f9d691de910b4a266f67bd314ef18ac49fa
foosecret773ba44693c7553d6ee20f61ea5d2757a9a4f4a44d2841ae4e95b52e4cd62db4

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) logged a real one as circleci-event-type: ping with:

{"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"].

EventFires whenstatus values
workflow-completedA workflow reached a terminal statesuccess, failed, error, canceled, unauthorized
job-completedA job reached a terminal statesuccess, failed, canceled, unauthorized (no error)

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

Headers

HeaderValue
content-typeapplication/json
user-agentCircleCI-Webhook/1.0
circleci-event-typeThe event type, e.g. workflow-completed
circleci-signaturev1=<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

{
  "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

# 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

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 — Both event types, the full envelope, vcs vs trigger_parameters, deduplication, retries, limits
  • references/setup.md — Configuring webhooks in Project Settings and via the v2 API, the Secret token, Test Ping Event
  • references/verification.md — The signature scheme byte by byte, test vectors, downgrade attacks, debugging failures

Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Sep 28, 2026

View on GitHub →