Skip to main content
All posts
Developers7 min read

Send a Shared Phone Alert When a Zoom Recording Is Ready

Verify Zoom's recording-completed webhook, queue one privacy-minimal event, and send a shared Ping without exposing recording details.

Two PingRoom avatars move a closed recording canister into a brass ready tray while Pingo waits beside an hourglass.

To send one shared phone alert when a Zoom cloud recording is ready, subscribe a Zoom app to recording.completed, verify the signed webhook in a small relay, and send a fixed message to a PingRoom Room. Use Zoom's own recording notification when the host or another named person only needs email. The relay is for a stable group that needs the same short signal without copying the meeting topic, recording link, transcript, or participants into the alert.

Zoom remains the place to open, review, share, and manage the recording. The Ping only answers: is the cloud recording ready for review?

Choose the smaller route first

What you needBest starting point
The host needs to know when processing finishesZoom's native recording notification
A named person should also receive the recording emailZoom's recording-email settings
One fixed signal for an opt-in groupZoom webhook → verified relay → PingRoom
Recording access, playback, transcript, or approvalKeep that work in Zoom and your governed review system
Guaranteed escalation or emergency responseUse a system designed and governed for that requirement

Zoom's New recording available setting can notify the host and, where the account setting allows it, selected alternatives such as an alternative host. If that covers the people who need the update, stop there. A custom relay introduces a public endpoint, secrets, a durable queue, retries, and monitoring.

Keep recording details out of the Ping

Use one fixed message:

  • Title: Zoom recording ready
  • Message: Open Zoom to review the cloud recording

Do not forward the meeting topic, UUID, host name, participant data, recording files, transcript, passcode, or share URL. The relay below uses the Zoom account ID and meeting UUID only to derive a one-way event key, then queues that opaque key. Zoom retains the useful details and permissions.

Two PingRoom avatars separate an accepted token from a duplicate while Pingo watches beside a brass bell.

In the illustration, one token reaches the accepted tray while its duplicate stays behind the divider. Pingo, PingRoom's fictional CMO, observes the boundary; he cannot enter a private Room, access Zoom, or send a Ping. The human team chooses the Zoom account, host, Room, message, and access policy.

Prepare Zoom and PingRoom

This is a webhook recipe, not a native Zoom integration in PingRoom.

  1. Create or choose a private PingRoom Room for the people who need the signal. Open Settings → Connections → From another app, add an incoming webhook with Custom message, and copy its complete URL into protected server-side configuration.
  2. In the Zoom App Marketplace, create an app that can subscribe to webhook events for the account you control. Add a public HTTPS event endpoint and subscribe to Recording → All Recordings have completed (recording.completed). Complete Zoom's endpoint validation and obtain the app's secret token.
  3. Limit the recipe to one expected Zoom account and one expected host. Store those IDs, the Zoom secret token, and the PingRoom webhook URL as server-side secrets. Do not put them in browser code, logs, screenshots, or a public repository.
  4. Enable cloud recording only for the Zoom users who need it. Zoom documents cloud recording for licensed users on paid accounts; account and group administrators can lock recording and notification settings. Confirm the plan, license, retention policy, consent practice, and app approval with the account owner before connecting live meetings.

The PingRoom Room owner needs active Pro for incoming webhooks, and a Room can have up to four. The incoming-webhook walkthrough covers the Room-side setup; the webhook reference carries current fields, limits, idempotency behavior, and errors.

Verify, filter, and queue before replying

Zoom signs the exact raw request body using the app secret token, the x-zm-request-timestamp, and HMAC-SHA256. Verify that signature before parsing JSON. This example also rejects a request more than three minutes from the server clock; keep the relay clock synchronized.

The dependency-free Node module below expects a Fetch-compatible runtime. Its queue.putOnce(key, value) adapter must durably insert once by key before it resolves. A queue worker calls deliverRecordingReady; a non-success PingRoom response must retry the same job and event key.

// zoom-relay.mjs
import { createHash, createHmac, timingSafeEqual } from "node:crypto";

function json(status, body) {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json; charset=utf-8" },
  });
}

function equalText(left, right) {
  const leftBytes = Buffer.from(left);
  const rightBytes = Buffer.from(right);
  return (
    leftBytes.length === rightBytes.length &&
    timingSafeEqual(leftBytes, rightBytes)
  );
}

function validZoomSignature(request, rawBody, secret, now = Date.now()) {
  const timestamp = request.headers.get("x-zm-request-timestamp") ?? "";
  const supplied = request.headers.get("x-zm-signature") ?? "";
  if (!/^\d{10}$/.test(timestamp)) return false;
  if (Math.abs(now / 1_000 - Number(timestamp)) > 3 * 60) return false;

  const expected = `v0=${createHmac("sha256", secret)
    .update(`v0:${timestamp}:${rawBody}`)
    .digest("hex")}`;
  return equalText(supplied, expected);
}

function selectedRecording(payload, env) {
  const recording = payload?.payload?.object;
  return (
    payload?.event === "recording.completed" &&
    payload.payload?.account_id === env.ZOOM_ACCOUNT_ID &&
    recording?.host_id === env.ZOOM_HOST_ID &&
    typeof recording.uuid === "string" &&
    recording.uuid.length > 0 &&
    recording.uuid.length <= 256
  );
}

function eventKey(accountId, meetingUuid) {
  return `zoom-recording:${createHash("sha256")
    .update(`${accountId}\0${meetingUuid}`)
    .digest("hex")}`;
}

export async function receiveZoomWebhook(request, env, queue) {
  if (request.method !== "POST") {
    return new Response("Method not allowed", {
      status: 405,
      headers: { allow: "POST" },
    });
  }

  const rawBody = await request.text();
  if (Buffer.byteLength(rawBody) > 65_536) {
    return json(413, { error: "payload_too_large" });
  }
  if (!validZoomSignature(request, rawBody, env.ZOOM_SECRET_TOKEN)) {
    return json(403, { error: "invalid_signature" });
  }

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

  if (payload?.event === "endpoint.url_validation") {
    const plainToken = payload?.payload?.plainToken;
    if (
      typeof plainToken !== "string" ||
      plainToken.length === 0 ||
      plainToken.length > 256
    ) {
      return json(400, { error: "invalid_plain_token" });
    }
    return json(200, {
      plainToken,
      encryptedToken: createHmac("sha256", env.ZOOM_SECRET_TOKEN)
        .update(plainToken)
        .digest("hex"),
    });
  }

  if (!selectedRecording(payload, env)) {
    return new Response(null, { status: 204 });
  }

  const key = eventKey(payload.payload.account_id, payload.payload.object.uuid);
  await queue.putOnce(key, { eventKey: key });
  return new Response(null, { status: 204 });
}

export async function deliverRecordingReady(job, env, fetchImpl = fetch) {
  const response = await fetchImpl(env.PINGROOM_WEBHOOK_URL, {
    method: "POST",
    redirect: "error",
    headers: {
      "content-type": "application/json",
      "Idempotency-Key": job.eventKey,
    },
    body: JSON.stringify({
      title: "Zoom recording ready",
      message: "Open Zoom to review the cloud recording",
      correlation_id: job.eventKey,
    }),
  });
  if (!response.ok) throw new Error(`PingRoom returned ${response.status}`);
}

Zoom's endpoint-validation event carries a plainToken; the response must echo it and add an HMAC-SHA256 encryptedToken. The code verifies the request first, then handles that challenge. For recording events, it requires the exact event, account, host, and nonempty meeting UUID.

The opaque SHA-256 key gives retries for the same account and meeting the same queue identity without retaining the UUID in the queued value. The worker reuses that key as PingRoom's Idempotency-Key and correlation_id. Queue insertion must finish before the receiver returns 204; otherwise an acknowledgement could arrive before the event is durable.

Test the boundaries, not just the happy path

The production check for this article extracted the module above and ran it against mocks. It covered the valid signature, three-minute freshness window, endpoint validation, exact event/account/host filters, stable deduplication, fixed private copy, PingRoom acceptance, and retry after a non-success response. No live Zoom app, meeting, public relay, PingRoom Room, customer data, or phone was used.

Before connecting real traffic, use a disposable Zoom app and a harmless recorded meeting:

  1. Complete endpoint validation; expect the plain and encrypted tokens Zoom requires.
  2. Deliver a signed recording.completed event for the selected account and host; expect one queued job and one fixed Ping.
  3. Change the event, account, or host; expect no Ping.
  4. Deliver the same recording event twice; expect one durable queue key and one PingRoom event identity.
  5. Change the body after signing it, then replay an otherwise valid event after the freshness window; expect both requests to fail verification.
  6. Make the queue write fail, then make PingRoom return an error; confirm the webhook receiver and worker retry without changing the event key.
  7. Lock a test phone and check the real delivery conditions your Room depends on.

PingRoom accepting an event means it entered the Room's normal delivery path. It does not prove that a particular phone displayed it: connectivity, notification permission, Focus settings, Room settings, and device state still matter.

Keep the signal smaller than the recording workflow

Do not turn the Room into a second recording index. Zoom should continue to own the recording, access control, retention, sharing, and review. PingRoom carries one short handoff moment to a known group.

If that boundary fits, create or choose the destination Room, add one incoming webhook, and rehearse the seven cases above with a disposable meeting before enabling the live subscription.

Sources

PingRoom authored this walkthrough. Zoom is a trademark of its owner. PingRoom is not affiliated with or endorsed by Zoom.

PingRoom

Short-form communication.

View all posts