Skip to main content
All posts
Automation7 min read

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.

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.

NeedBetter starting point
A developer needs normal build detailsCircleCI dashboard or email
The engineering team already works in SlackCircleCI's Slack integration
A deployment or approval needs its native project contextCircleCI deployment or approval notifications
One selected success should reach a separate bounded group of phonesCircleCI outbound webhook → relay → PingRoom Room
People need logs, reruns, artifacts, or an audit historyKeep CircleCI as the system of record
A failed release needs paging or escalationKeep 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 covers the Room-side steps. The webhook reference 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 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.

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 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 outcomeHTTP resultMeaning
Signed selected workflow success accepted200PingRoom accepted the fixed event
Signed test ping, another workflow/project, or non-success204No Ping is needed
Invalid signature or altered body401Sender identity is not trusted
Invalid event ID or malformed JSON400The event cannot be mapped safely
PingRoom rejects, times out, or returns bad JSON503Handoff 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.

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 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, deploy the relay, and run all eight checks with non-sensitive data. Use the broader CI/CD guide before adding any other workflow state.

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

PingRoom

Short-form communication.

View all posts