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

> Verify Travis CI's signed final-build webhook, filter one repository and branch, and forward fixed, replay-safe success copy to a PingRoom Room.

- Published: 2026-10-29
- Author: PingRoom
- Category: Automation
- Topics: Travis CI notifications, Travis CI webhook, build succeeded notification, automation notifications to phone
- Canonical: https://pingroom.io/blog/travis-ci-build-success-push-notifications
- Cover image: https://pingroom.io/blog/travis-ci-build-success-push-notifications-cover.webp
- Cover description: Two PingRoom avatars fit the last span into a miniature clay bridge while Pingo watches beside a closed folio.

**To send one Travis CI build-success alert to a small team's phones, configure a final-build webhook for one repository, verify its signed payload in a private relay, accept only a successful push build on one branch, and forward fixed copy to a PingRoom incoming webhook.** Use Travis CI's build ID as the PingRoom idempotency key so a webhook retry does not become another alert.

This is a webhook recipe, not a native Travis CI integration. The Room owner's PingRoom account needs **Pro**. You also need permission to edit the repository's `.travis.yml` and somewhere private to run the relay.

## First, use Travis CI's native notifications when they fit

Travis CI already supports [email, Slack, and Pushover notifications](https://docs.travis-ci.com/user/notifications). Pushover is the shortest route when the need is simply a personal phone notification or a Pushover group that your team already maintains.

| What the group needs                                            | Better starting point                          |
| --------------------------------------------------------------- | ---------------------------------------------- |
| Build logs, job status, or a restart                            | Travis CI build results                        |
| A personal inbox update                                         | Travis CI Email                                |
| An engineering-channel update                                   | Travis CI Slack                                |
| Direct phone alerts for existing Pushover users                 | Travis CI Pushover                             |
| One fixed success signal for a separate, stable group of phones | Travis webhook → private relay → PingRoom Room |
| Paging, escalation, acknowledgments, or an incident record      | Your incident system                           |

The phone alert below says only that Travis CI reported one selected build as passed. It does not prove 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`. Travis CI calls the relay, not PingRoom directly. Travis sends a form-encoded signed payload; the relay must verify that signature, select the intended build, and remove build details before creating the shared signal.

Give the relay three more settings:

- `TRAVIS_REPO_SLUG`: the exact `owner/repository`, such as `acme/mobile-app`;
- `TRAVIS_BRANCH`: the exact branch to accept, such as `main`;
- `PINGROOM_WEBHOOK_URL`: the complete secret PingRoom URL.

Expose the relay only over HTTPS. Give it ordinary platform protections: a small body limit, a short execution timeout, request-rate controls, and logs that never print the signature, payload, or PingRoom URL.

## Configure one final-build webhook

Add the relay URL to `.travis.yml`:

```yaml
notifications:
  webhooks:
    urls:
      - https://relay.example.com/travis
    on_success: always
    on_failure: never
    on_start: never
    on_cancel: never
    on_error: never
```

Travis CI's [webhook configuration reference](https://config.travis-ci.com/ref/notifications/webhooks) defines those controls. Its notification guide says a webhook is sent after a build finishes. The receiver still checks the signed event fields; configuration can change, and a public endpoint should not assume every valid Travis payload belongs in this Room.

If your Travis organization uses a different official API host, retrieve the public key from that matching host. This example uses `api.travis-ci.com` and fetches its current key from `https://api.travis-ci.com/config`.

## Verify, filter, and forward fixed copy

The handler below is dependency-free. Adapt the exported function to the POST route in your runtime. It accepts exactly one form field named `payload`, verifies the `Signature` header over that JSON string with Travis CI's published RSA key, then checks the repository header and signed body before forwarding anything.

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

const MAX_BODY_BYTES = 96_000;
const SAFE_BUILD_ID = /^\d{1,20}$/;
const SAFE_REPO_SLUG = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const SAFE_BRANCH = /^[A-Za-z0-9._/-]{1,255}$/;
const TRAVIS_CONFIG_URL = "https://api.travis-ci.com/config";

let cachedTravisPublicKey;

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

async function fetchTravisPublicKey(fetchImpl) {
  if (cachedTravisPublicKey) return cachedTravisPublicKey;
  const response = await fetchImpl(TRAVIS_CONFIG_URL, {
    method: "GET",
    redirect: "error",
    signal: AbortSignal.timeout(5_000),
    headers: { Accept: "application/json" },
  });
  if (!response.ok) throw new Error("travis_config_unavailable");

  const config = await response.json();
  const publicKey = config?.config?.notifications?.webhook?.public_key;
  if (
    typeof publicKey !== "string" ||
    !publicKey.startsWith("-----BEGIN PUBLIC KEY-----") ||
    publicKey.length > 8_000
  ) {
    throw new Error("travis_public_key_invalid");
  }
  cachedTravisPublicKey = publicKey;
  return publicKey;
}

function hasValidSignature(payload, signature, publicKey) {
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(signature ?? "")) return false;
  try {
    const verifier = createVerify("RSA-SHA1");
    verifier.update(payload, "utf8");
    verifier.end();
    return verifier.verify(publicKey, Buffer.from(signature, "base64"));
  } catch {
    return false;
  }
}

export async function handleTravisWebhook(request, env, deps = {}) {
  const fetchImpl = deps.fetchImpl ?? fetch;
  const getKey =
    deps.getTravisPublicKey ?? (() => fetchTravisPublicKey(fetchImpl));

  if (request.method !== "POST")
    return reply(405, { error: "method_not_allowed" });

  const { TRAVIS_REPO_SLUG, TRAVIS_BRANCH, PINGROOM_WEBHOOK_URL } = env;
  if (
    !SAFE_REPO_SLUG.test(TRAVIS_REPO_SLUG ?? "") ||
    !SAFE_BRANCH.test(TRAVIS_BRANCH ?? "") ||
    !PINGROOM_WEBHOOK_URL?.startsWith("https://")
  ) {
    return reply(500, { error: "server_configuration_error" });
  }

  if (
    !request.headers
      .get("content-type")
      ?.toLowerCase()
      .startsWith("application/x-www-form-urlencoded")
  ) {
    return reply(415, { error: "unsupported_media_type" });
  }

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

  const form = new URLSearchParams(rawBody);
  const payloadValues = form.getAll("payload");
  if (payloadValues.length !== 1 || !payloadValues[0]) {
    return reply(400, { error: "invalid_form_body" });
  }
  const payloadText = payloadValues[0];

  let publicKey;
  try {
    publicKey = await getKey();
  } catch {
    return reply(502, { error: "travis_key_unavailable" });
  }
  if (
    !hasValidSignature(payloadText, request.headers.get("signature"), publicKey)
  ) {
    return reply(401, { error: "invalid_signature" });
  }

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

  const eventRepo = `${event?.repository?.owner_name ?? ""}/${event?.repository?.name ?? ""}`;
  const headerRepo = request.headers.get("travis-repo-slug");
  if (headerRepo !== TRAVIS_REPO_SLUG || eventRepo !== TRAVIS_REPO_SLUG)
    return reply(204);
  if (event.type !== "push" || event.branch !== TRAVIS_BRANCH)
    return reply(204);
  if (
    event.state !== "passed" ||
    event.status !== 0 ||
    event.result !== 0 ||
    typeof event.finished_at !== "string"
  ) {
    return reply(204);
  }
  if (!SAFE_BUILD_ID.test(String(event.id ?? ""))) {
    return reply(400, { error: "invalid_build_id" });
  }

  const pingBody = JSON.stringify({
    action: null,
    title: "Travis CI build succeeded",
    message: "Selected Travis CI 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": `travis-build:${event.id}`,
      },
      body: pingBody,
    });
  } catch {
    return reply(502, { error: "pingroom_unavailable" });
  }

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

Travis CI's notification guide documents the form body, `Signature`, `Travis-Repo-Slug`, and status fields. The signed payload check is the trust boundary; the repository header is an extra routing cross-check. The relay caches only a key fetched from the fixed official host, refuses redirects for both outbound requests, and returns `502` when it cannot verify or forward so the failure remains visible at the relay.

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

![Two PingRoom avatars inspect one terracotta input token and one accepted ivory signal at a brass gauge while Pingo waits beside a closed credential box.](/blog/travis-ci-build-success-push-notifications-1.webp)

_Pingo, PingRoom's fictional CMO, is watching the verification bench. He has no private-Room access or authority to send anything; the humans chose the signal, the relay enforces it, and his expression remains an editorial contribution._

## Test twelve boundaries before enabling the webhook

Use a harmless repository and a test Room first:

1. One correctly signed, finished `passed` push build for the selected repository and branch creates one fixed Ping.
2. Replaying the same build ID does not create changed or duplicate content.
3. A failed build creates no success Ping.
4. A pull-request build creates no Ping.
5. A success from another branch creates no Ping.
6. Another repository in either the header or signed body creates no Ping.
7. A missing, malformed, or incorrect signature is rejected.
8. Missing or duplicate `payload` fields are rejected.
9. A non-form content type or oversized body is rejected before forwarding.
10. An unavailable or malformed Travis public key stops processing.
11. A redirect or non-success from PingRoom is treated as a failed delivery.
12. The phone preview contains no repository, branch, commit, actor, build number, status detail, or link.

For this article, the exact handler passed twelve controlled cases against Travis-shaped fixtures, a generated RSA test key, and a stateful local mock of PingRoom's documented contract. It accepted one unique mock Ping. A separate live read of Travis CI's config endpoint returned HTTP 200 and a PEM-shaped public key. No live Travis build, deployed relay, PingRoom Room, phone, or network delivery was tested.

If this fixed success signal belongs outside Travis CI's native channels, [create the private Room](/app/rooms), add only the intended people, and test the smallest path before enabling the webhook on a production repository.
