# How to Send a TeamCity Build-Success Alert to Your Team's Phones

> Configure TeamCity's BUILD_FINISHED webhook for one build configuration, then forward only verified successes as fixed, replay-safe copy to PingRoom.

- Published: 2026-10-29
- Author: PingRoom
- Category: Automation
- Topics: TeamCity notifications, TeamCity webhook, build succeeded notification, automation notifications to phone
- Canonical: https://pingroom.io/blog/teamcity-build-success-push-notifications
- Cover image: https://pingroom.io/blog/teamcity-build-success-push-notifications-cover.webp
- Cover description: Two PingRoom avatars inspect one finished clay build token at a miniature workshop while Pingo watches beside a closed folio.

**To send one TeamCity build-success alert to a small team's phones, configure TeamCity's `BUILD_FINISHED` webhook to call a private relay, authenticate its custom headers, accept only one build configuration with `status: SUCCESS`, and forward fixed copy to a PingRoom incoming webhook.** Use TeamCity's unique build ID as the PingRoom idempotency key so a retry does not become a second alert.

This is a webhook recipe, not a native TeamCity integration. The Room owner's PingRoom account needs **Pro**. Someone with access to the relevant TeamCity project parameters must configure the sender and store its password as a Password-type parameter.

## First, use TeamCity's native notifications when they fit

TeamCity already supports personal notification rules for successful builds. Its Notifications build feature also supports [Email and Slack](https://www.jetbrains.com/help/teamcity/notifications.html) for build-configuration-level delivery. Those are the simplest starting points when the intended people already watch those channels.

| What the group needs                                            | Better starting point                            |
| --------------------------------------------------------------- | ------------------------------------------------ |
| Logs, test failures, artifacts, or a rerun                      | TeamCity build results                           |
| A personal success rule, including first success after failure  | TeamCity notification rules                      |
| An engineering-channel update                                   | TeamCity's Slack notifier                        |
| A shared mailbox update                                         | TeamCity's Email notifier                        |
| One fixed success signal for a separate, stable group of phones | TeamCity webhook → private relay → PingRoom Room |
| Paging, escalation, acknowledgments, or an incident record      | Your incident system                             |

The phone alert below says only that TeamCity reported one selected build as finished and successful. It does not prove that a deployment happened, an artifact is safe, production is healthy, or every phone displayed the notification. For the broader choice of signal, audience, and escalation path, start with the [CI/CD notification guide](/blog/ci-cd-deployment-notifications).

## Create the Room and keep its webhook URL behind the relay

Create or choose a private Room for the stable group that needs this one signal. Open **Settings → Connections → From another app**, add an incoming webhook, and copy its complete URL. The [incoming-webhook walkthrough](/blog/webhook-to-push-notification) covers the Room-side setup; the [webhook reference](/documents/webhooks) documents current fields, limits, cooldowns, and replay behavior.

Treat that URL as a credential. Store it only in the relay's server-side secret store as `PINGROOM_WEBHOOK_URL`. TeamCity calls the relay URL, not PingRoom directly, because the relay must authenticate TeamCity, select one build configuration, and remove build details before creating the shared signal.

Give the relay four settings:

- `TEAMCITY_USER`: a dedicated relay username;
- `TEAMCITY_SECRET`: a long random secret;
- `TEAMCITY_BUILD_TYPE`: the exact TeamCity build configuration ID, such as `Mobile_ReleaseCandidate`;
- `PINGROOM_WEBHOOK_URL`: the complete secret PingRoom URL.

Expose the relay only over HTTPS. Put ordinary platform protections in front of it: a small request-body limit, a short execution timeout, request-rate controls, and logs that never print either secret or the PingRoom URL.

## Configure the smallest TeamCity payload

In the TeamCity project that owns the selected build configuration, open **Administration → Project → Parameters**. Add these configuration parameters:

| Parameter                                          | Value                                                           |
| -------------------------------------------------- | --------------------------------------------------------------- |
| `teamcity.internal.webhooks.enable`                | `true`                                                          |
| `teamcity.internal.webhooks.url`                   | The relay's HTTPS `/teamcity` endpoint                          |
| `teamcity.internal.webhooks.events`                | `BUILD_FINISHED`                                                |
| `teamcity.internal.webhooks.BUILD_FINISHED.fields` | `fields=id,buildTypeId,status,state`                            |
| `teamcity.internal.webhooks.username`              | The same value as `TEAMCITY_USER`                               |
| `teamcity.internal.webhooks.password`              | The same secret as `TEAMCITY_SECRET`, stored with Password type |
| `teamcity.internal.webhooks.retry_count`           | `2` for this example                                            |

TeamCity's [webhook documentation](https://www.jetbrains.com/help/teamcity/cloud/teamcity-webhooks.html) says `BUILD_FINISHED` fires whether the build succeeded or failed, so the receiver still has to check `status`. It also says the username and password arrive in the `php-auth-user` and `php-auth-pw` headers. Check those exact headers; do not assume TeamCity sent HTTP Basic authentication.

Project parameters can be inherited. Put this configuration at the narrowest project that should emit these events, then keep the relay's exact `buildTypeId` check even if inheritance changes later.

## Authenticate, filter, and forward fixed copy

The handler below is dependency-free. Adapt the exported function to the POST route in your runtime. Its secret comparison uses equal-length buffers, its body is bounded, and irrelevant events receive `204` so TeamCity does not retry them.

```js
import { timingSafeEqual } from "node:crypto";

const MAX_BODY_BYTES = 64_000;
const SAFE_BUILD_TYPE = /^[A-Za-z0-9_.-]{1,255}$/;
const SAFE_BUILD_ID = /^\d{1,20}$/;

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

function sameSecret(actual, expected) {
  if (typeof actual !== "string" || typeof expected !== "string") return false;
  const left = new TextEncoder().encode(actual);
  const right = new TextEncoder().encode(expected);
  return left.length === right.length && timingSafeEqual(left, right);
}

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

  const {
    TEAMCITY_USER,
    TEAMCITY_SECRET,
    TEAMCITY_BUILD_TYPE,
    PINGROOM_WEBHOOK_URL,
  } = env;
  if (
    !TEAMCITY_USER ||
    !TEAMCITY_SECRET ||
    !SAFE_BUILD_TYPE.test(TEAMCITY_BUILD_TYPE ?? "") ||
    !PINGROOM_WEBHOOK_URL?.startsWith("https://")
  ) {
    return reply(500, { error: "server_configuration_error" });
  }

  if (
    !sameSecret(request.headers.get("php-auth-user"), TEAMCITY_USER) ||
    !sameSecret(request.headers.get("php-auth-pw"), TEAMCITY_SECRET)
  ) {
    return reply(401, { error: "unauthorized" });
  }

  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" });
  }

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

  const build = event.payload;
  if (!SAFE_BUILD_ID.test(String(build?.id ?? ""))) {
    return reply(400, { error: "invalid_build_id" });
  }
  if (build.buildTypeId !== TEAMCITY_BUILD_TYPE) return reply(204);
  if (build.status !== "SUCCESS" || build.state !== "finished")
    return reply(204);

  const pingBody = JSON.stringify({
    action: null,
    title: "TeamCity build succeeded",
    message: "Selected TeamCity build finished successfully",
  });

  let downstream;
  try {
    downstream = await fetchImpl(PINGROOM_WEBHOOK_URL, {
      method: "POST",
      redirect: "manual",
      signal: AbortSignal.timeout(8_000),
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        "Idempotency-Key": `teamcity-build:${build.id}`,
      },
      body: pingBody,
    });
  } catch {
    return reply(502, { error: "pingroom_unavailable" });
  }

  if (!downstream.ok) return reply(502, { error: "pingroom_rejected" });
  return reply(204);
}
```

The outgoing body is fixed. Repository names, branch names, commit messages, actors, build numbers, status text, and links never enter the shared Ping. The TeamCity build ID appears only in `Idempotency-Key`, where PingRoom can map an exact retry to the original accepted request.

TeamCity retries webhook requests only when `teamcity.internal.webhooks.retry_count` is greater than zero and the receiver throws or returns a non-2xx status. That is why the relay returns `502` when PingRoom is unavailable, but acknowledges unrelated or unsuccessful builds with `204`.

![Two PingRoom avatars test one accepted clay token and one stopped replay at a brass receiver gate while Pingo watches beside a closed credential capsule.](/blog/teamcity-build-success-push-notifications-1.webp)

_Pingo, PingRoom's fictional CMO, is present for the replay test. He has no private-Room access or authority to send anything; the humans chose the signal, the receiver enforces it, and the restrained expression is strictly editorial._

## Test ten boundaries before using the Room

Use a harmless build configuration and a test Room first:

1. One finished `SUCCESS` event for the selected build configuration creates one Ping with the fixed copy.
2. Replaying the same TeamCity build ID does not create changed or duplicate content.
3. A failed build creates no success Ping.
4. A success from another build configuration creates no Ping.
5. A different event type creates no Ping.
6. Missing or incorrect TeamCity secret headers are rejected.
7. Malformed and oversized bodies are rejected before forwarding.
8. A redirect from the PingRoom URL is treated as a failed delivery.
9. A non-success PingRoom response is returned to TeamCity as a retryable failure.
10. The phone preview contains no repository, branch, commit, actor, build number, status detail, or link.

For this article, the exact handler passed ten controlled cases against TeamCity-shaped fixtures and a stateful local mock of PingRoom's documented contract. It accepted one target success, replayed it without a second unique Ping, ignored a failure, another build configuration, and another event, and rejected bad credentials, malformed or oversized input, a redirect, and a downstream failure. The check used no live TeamCity server, PingRoom Room, phone, or network request.

If this fixed success signal belongs outside the channels your team already watches, [create the private Room](/app/rooms), add only the intended people, and test the smallest possible path before enabling retries on a production project.
