# How to Send CircleCI Workflow Completion Alerts to Your Team's Phones

> Verify CircleCI's signed workflow-completed webhook, filter one project and workflow, and send a privacy-minimal success Ping to a shared team Room.

- Published: 2026-10-25
- Author: PingRoom
- Category: Automation
- Topics: CircleCI workflow notifications, CircleCI webhook push notification, deployment complete notification, automation notifications to phone
- Canonical: https://pingroom.io/blog/circleci-workflow-completion-push-notifications
- Cover image: https://pingroom.io/blog/circleci-workflow-completion-push-notifications-cover.webp
- Cover description: Two PingRoom avatars inspect the final token on a clay workflow track while Pingo watches beside a closed release folio.

**To send a CircleCI workflow-completion alert to a small team's phones, create a signed `workflow-completed` webhook, verify its raw body in a relay, filter one project and workflow, and send fixed success copy to a PingRoom incoming webhook.** Use CircleCI's event ID for retry identity, and leave repository, branch, commit, URL, and log details in CircleCI.

This is a webhook recipe, not a native CircleCI integration. The CircleCI user creating the project webhook needs organization-admin permission. The destination Room owner needs **PingRoom Pro**.

## Check CircleCI's own notifications first

CircleCI already offers email, Slack, deployment, and approval notifications. Use those when they reach the right people in a surface the team already follows.

| Need                                                                 | Better starting point                             |
| -------------------------------------------------------------------- | ------------------------------------------------- |
| A developer needs normal build details                               | CircleCI dashboard or email                       |
| The engineering team already works in Slack                          | CircleCI's Slack integration                      |
| A deployment or approval needs its native project context            | CircleCI deployment or approval notifications     |
| One selected success should reach a separate bounded group of phones | CircleCI outbound webhook → relay → PingRoom Room |
| People need logs, reruns, artifacts, or an audit history             | Keep CircleCI as the system of record             |
| A failed release needs paging or escalation                          | Keep the established incident system              |

This guide sends only `status: "success"` for one named workflow. CircleCI also reports `failed`, `error`, `canceled`, and `unauthorized` terminal states. Do not quietly route those into a cheerful completion message. Give failures their existing engineering or incident path.

## Create the destination Room

Create or choose a private PingRoom Room for the stable group that needs the completion signal. Open **Settings → Connections → From another app**, add a webhook named **CircleCI completion**, choose **Custom message**, and copy the full URL.

The [incoming-webhook setup guide](/blog/webhook-to-push-notification) covers the Room-side steps. The [webhook reference](/documents/webhooks) documents current fields, limits, idempotency behavior, and errors.

Store these five values in protected server configuration:

- `CIRCLECI_WEBHOOK_SECRET`: a strong secret that you will also enter in CircleCI;
- `CIRCLECI_PROJECT_ID`: the exact CircleCI project UUID allowed to send;
- `CIRCLECI_WORKFLOW_NAME`: the exact workflow name, such as `build-test-deploy`;
- `PINGROOM_WEBHOOK_URL`: the complete secret PingRoom webhook URL;
- `PINGROOM_PROJECT_LABEL`: short, reviewed copy such as `Website`.

Do not place either secret in browser code, source control, screenshots, build logs, or the Ping itself.

## Verify the raw request before parsing it

CircleCI's [`circleci-signature`](https://circleci.com/docs/guides/integration/outbound-webhooks/) header can contain versioned signatures. The current `v1` value is an HMAC-SHA256 digest of the raw request body. Verify that exact body before parsing JSON.

The following dependency-free handler uses Web Crypto, so the core verification works in a modern Worker-style runtime. Pass the platform request and environment bindings to `handleCircleCIWebhook`.

```js
const MAX_BODY_BYTES = 256_000;
const HEX_SHA256 = /^[a-f0-9]{64}$/i;
const SAFE_EVENT_ID = /^[a-z0-9._:-]{1,200}$/i;

function reply(status, body = null) {
  if (body === null) return new Response(null, { status });
  return Response.json(body, { status });
}

function getV1Signature(header) {
  if (!header) return null;
  const matches = header
    .split(",")
    .map((part) => part.trim())
    .filter((part) => part.startsWith("v1="))
    .map((part) => part.slice(3));

  return matches.length === 1 && HEX_SHA256.test(matches[0])
    ? matches[0].toLowerCase()
    : null;
}

function fromHex(value) {
  return Uint8Array.from(value.match(/../g), (byte) =>
    Number.parseInt(byte, 16),
  );
}

async function verifyCircleCISignature(header, rawBody, secret) {
  const signature = getV1Signature(header);
  if (!signature) return false;

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );

  return crypto.subtle.verify(
    "HMAC",
    key,
    fromHex(signature),
    new TextEncoder().encode(rawBody),
  );
}

export async function handleCircleCIWebhook(request, env, fetchImpl = fetch) {
  if (request.method !== "POST") {
    return reply(405, { error: "method_not_allowed" });
  }

  const {
    CIRCLECI_WEBHOOK_SECRET,
    CIRCLECI_PROJECT_ID,
    CIRCLECI_WORKFLOW_NAME,
    PINGROOM_WEBHOOK_URL,
    PINGROOM_PROJECT_LABEL,
  } = env;

  if (
    !CIRCLECI_WEBHOOK_SECRET ||
    !CIRCLECI_PROJECT_ID ||
    !CIRCLECI_WORKFLOW_NAME ||
    !PINGROOM_WEBHOOK_URL?.startsWith("https://") ||
    !PINGROOM_PROJECT_LABEL ||
    PINGROOM_PROJECT_LABEL.length > 48
  ) {
    return reply(500, { error: "server_configuration_error" });
  }

  const declaredLength = Number(request.headers.get("content-length") ?? 0);
  if (declaredLength > MAX_BODY_BYTES) {
    return reply(413, { error: "body_too_large" });
  }

  const rawBody = await request.text();
  if (new TextEncoder().encode(rawBody).byteLength > MAX_BODY_BYTES) {
    return reply(413, { error: "body_too_large" });
  }

  if (
    !(await verifyCircleCISignature(
      request.headers.get("circleci-signature"),
      rawBody,
      CIRCLECI_WEBHOOK_SECRET,
    ))
  ) {
    return reply(401, { error: "invalid_signature" });
  }

  let event;
  try {
    event = JSON.parse(rawBody);
  } catch {
    return reply(400, { error: "invalid_json" });
  }

  if (
    request.headers.get("circleci-event-type") !== "workflow-completed" ||
    event.type !== "workflow-completed"
  ) {
    return reply(204);
  }

  if (!SAFE_EVENT_ID.test(String(event.id ?? ""))) {
    return reply(400, { error: "invalid_event_id" });
  }

  if (
    event.project?.id !== CIRCLECI_PROJECT_ID ||
    event.workflow?.name !== CIRCLECI_WORKFLOW_NAME ||
    event.workflow?.status !== "success"
  ) {
    return reply(204);
  }

  const eventKey = `circleci-${event.id}`;
  const payload = {
    title: "CircleCI workflow complete",
    message: `${PINGROOM_PROJECT_LABEL} finished successfully. Check CircleCI before acting.`,
    correlation_id: eventKey,
  };

  try {
    const response = await fetchImpl(PINGROOM_WEBHOOK_URL, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        "Idempotency-Key": eventKey,
      },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(8_000),
    });

    if (!response.ok) {
      return reply(503, { error: "pingroom_rejected" });
    }

    const accepted = await response.json().catch(() => null);
    if (accepted?.success !== true) {
      return reply(503, { error: "pingroom_invalid_response" });
    }

    return reply(200, { ok: true });
  } catch {
    return reply(503, { error: "pingroom_unavailable" });
  }
}
```

The handler deliberately ignores the project name, repository URL, branch, commit, author, workflow URL, and timing fields. `PINGROOM_PROJECT_LABEL` is reviewed server configuration, not text copied from an event. If even the project label is sensitive, use neutral copy such as **Selected workflow**.

## Add the CircleCI webhook

CircleCI currently limits a project to five outbound webhooks, and organization-admin permission is required to create or change them.

1. In CircleCI, choose the organization and open **Projects**.
2. Open the intended project's **Project Settings → Webhooks**.
3. Select **Add Webhook** and give it a descriptive name.
4. Enter the public HTTPS URL of the relay and keep certificate validation enabled.
5. Enter the same strong secret stored as `CIRCLECI_WEBHOOK_SECRET`.
6. Select **Workflow Completed** and save.
7. Use **Test Ping Event**. A signed test ping should receive `204` because it is not a workflow completion.
8. Run one harmless successful workflow and confirm that exactly one fixed Ping is accepted.

CircleCI's [webhook payload reference](https://circleci.com/docs/reference/outbound-webhooks-reference/) describes `id` as the unique event identifier. The relay reuses it in both `correlation_id` and `Idempotency-Key`. A duplicate delivery therefore produces the same request identity and exact body.

## Keep the completion claim narrow

“Workflow complete” means the selected CircleCI workflow reached its terminal `success` state. It does not prove that a deployment is reachable, every dependency is healthy, a release was approved, or every phone displayed the Ping.

| Relay outcome                                              | HTTP result | Meaning                            |
| ---------------------------------------------------------- | ----------: | ---------------------------------- |
| Signed selected workflow success accepted                  |         200 | PingRoom accepted the fixed event  |
| Signed test ping, another workflow/project, or non-success |         204 | No Ping is needed                  |
| Invalid signature or altered body                          |         401 | Sender identity is not trusted     |
| Invalid event ID or malformed JSON                         |         400 | The event cannot be mapped safely  |
| PingRoom rejects, times out, or returns bad JSON           |         503 | Handoff failed; CircleCI may retry |

CircleCI expects a `2xx` response, currently times out a delivery after ten seconds, retries non-`2xx` or timed-out attempts later, and warns that requests can be duplicated. The relay's eight-second downstream timeout leaves a small margin, but a production system with stronger delivery requirements should acknowledge into a durable queue and process the PingRoom handoff separately.

![Two PingRoom avatars close a blank release folio beside one sealed notification capsule while Pingo gives it a sideways glance.](/blog/circleci-workflow-completion-push-notifications-inline.webp)

_These AI-generated editorial illustrations feature approved PingRoom avatars and Pingo the Pigeon, PingRoom's fictional Chief Marketing Officer. They are not CircleCI or PingRoom screenshots and do not depict customers._

[Pingo the Pigeon](/Pingo) is watching a sealed capsule settle beside the finished track. He has no CircleCI account, private-Room access, credentials, or authority to send Pings. People choose the project, workflow, destination, copy, and response procedure.

## Test the boundary before relying on it

Use locally signed fixtures first, then one harmless real workflow:

1. Sign a selected `success` fixture and confirm one fixed Ping is accepted.
2. Replay the same event ID and confirm the request uses the same key and body.
3. Change the project ID and confirm the relay returns `204` without calling PingRoom.
4. Change the workflow name and confirm it is ignored.
5. Change the status to `failed` and confirm no success Ping is sent.
6. Alter the body after signing and confirm the relay returns `401`.
7. Make PingRoom return `429` and confirm the relay reports `503` to keep the failed handoff visible.
8. Check the test phone and confirm the Ping contains no repository, branch, commit, author, URL, log, or error detail.

For this article, we imported the exact handler above and ran it against locally signed CircleCI-style fixtures with a stateful PingRoom mock. Eight cases covered success, exact replay, project filtering, workflow filtering, non-success, test ping, invalid signature, and downstream rejection. No live CircleCI project, PingRoom Room, phone, or network request was used.

If one CircleCI success deserves a separate shared signal, [create the destination Room and webhook](/app/rooms), deploy the relay, and run all eight checks with non-sensitive data. Use the [broader CI/CD guide](/blog/ci-cd-deployment-notifications) before adding any other workflow state.

_CircleCI is a trademark of Circle Internet Services, Inc. PingRoom is not affiliated with or endorsed by CircleCI._
