Skip to main content
All posts
Automation7 min read

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.

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. 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 needsBetter starting point
Build logs, job status, or a restartTravis CI build results
A personal inbox updateTravis CI Email
An engineering-channel updateTravis CI Slack
Direct phone alerts for existing Pushover usersTravis CI Pushover
One fixed success signal for a separate, stable group of phonesTravis webhook → private relay → PingRoom Room
Paging, escalation, acknowledgments, or an incident recordYour 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.

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 covers the Room-side setup; the webhook reference 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:

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 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.

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.

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, add only the intended people, and test the smallest path before enabling the webhook on a production repository.

PingRoom

Short-form communication.

View all posts