# How to Send Vercel Production Deployment Alerts to Your Team's Phones

> Verify Vercel's signed deployment.promoted webhook, keep deployment details out of the alert, and send one fixed Ping to a shared release Room.

- Published: 2026-10-24
- Author: PingRoom
- Category: Automation
- Topics: Vercel deployment push notification, Vercel production webhook alert, deployment notifications, automation notifications to phone
- Canonical: https://pingroom.io/blog/vercel-production-deployment-push-notifications
- Cover image: https://pingroom.io/blog/vercel-production-deployment-push-notifications-cover.webp
- Cover description: Two PingRoom avatars watch one verified deployment token enter a warm production alcove while Pingo stands beside the gate.

**To send one Vercel production deployment alert to a shared group of phones, subscribe a Vercel account webhook to _Deployment Promoted_, verify its signature in a small relay, and forward fixed copy to a PingRoom incoming webhook.** Use Vercel's delivery ID as the retry identity. Keep commit messages, authors, URLs, logs, and deployment metadata in Vercel.

This is a webhook recipe, not a native Vercel integration. It requires a Vercel Pro or Enterprise team for account webhooks and PingRoom Pro for the destination Room's incoming webhook.

## Check Vercel's own notifications first

Vercel already offers configurable dashboard and email notifications on all plans. Its [notification guide](https://vercel.com/docs/notifications) also documents opt-in push notifications on desktop and mobile web. Those preferences belong to each person; one team member cannot configure another member's channels.

Start with the smaller path:

| Need                                                                    | Better starting point                           |
| ----------------------------------------------------------------------- | ----------------------------------------------- |
| One account holder needs Vercel updates                                 | Vercel's own notification settings              |
| A team already follows deployment status in Slack                       | Vercel's Slack integration                      |
| One selected production promotion should reach a bounded shared group   | Vercel account webhook → relay → PingRoom Room  |
| People need logs, checks, aliases, commit details, or rollback controls | Open the deployment in Vercel                   |
| A release needs paging, escalation, or an immutable operational record  | Keep the established release or incident system |

Do not forward every preview build. This guide uses **Deployment Promoted** because Vercel defines that event as a production deployment beginning to serve production traffic. It is a more useful human signal than “a build exists.”

## Create the destination Room

Create or choose a private PingRoom Room for the people who need this production update. The Room owner needs **PingRoom Pro** for incoming webhooks.

Open **Settings → Connections → From another app**, add a webhook named **Vercel production**, choose **Custom message**, and copy the full URL. The [incoming-webhook setup guide](/blog/webhook-to-push-notification) explains the Room-side steps, while the [webhook reference](/documents/webhooks) lists request limits, idempotency behavior, and error responses.

Store the URL as `PINGROOM_WEBHOOK_URL` in protected server configuration. It is a credential. Do not put it in a Vercel environment exposed to the browser, source control, screenshots, or logs.

The relay also needs:

- `VERCEL_WEBHOOK_SECRET`: the one-time secret Vercel shows after the account webhook is created;
- `VERCEL_PROJECT_ID`: the selected project's `prj_…` identifier;
- `PINGROOM_PROJECT_LABEL`: a short approved label for the lock screen, such as `Website`.

## Verify the raw body before parsing

Vercel's [webhook reference](https://vercel.com/docs/webhooks/webhooks-api) says `x-vercel-signature` is an HMAC-SHA1 digest of the raw request body using the webhook secret. Read the exact bytes once, calculate the expected digest, and compare it in constant time before parsing JSON.

The following Web-standard handler works in a Node runtime. Adapt only the route wrapper for your framework.

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

const MAX_BODY_BYTES = 256_000;

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

function signaturesMatch(received, expected) {
  if (!/^[a-f0-9]{40}$/i.test(received ?? "")) return false;
  const actual = Buffer.from(received, "hex");
  const wanted = Buffer.from(expected, "hex");
  return actual.length === wanted.length && timingSafeEqual(actual, wanted);
}

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

  const {
    VERCEL_WEBHOOK_SECRET,
    VERCEL_PROJECT_ID,
    PINGROOM_WEBHOOK_URL,
    PINGROOM_PROJECT_LABEL,
  } = env;

  if (
    !VERCEL_WEBHOOK_SECRET ||
    !VERCEL_PROJECT_ID?.startsWith("prj_") ||
    !PINGROOM_WEBHOOK_URL?.startsWith("https://") ||
    !PINGROOM_PROJECT_LABEL ||
    PINGROOM_PROJECT_LABEL.length > 50
  ) {
    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 (Buffer.byteLength(rawBody) > MAX_BODY_BYTES) {
    return reply(413, { error: "body_too_large" });
  }

  const expected = createHmac("sha1", VERCEL_WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex");
  if (!signaturesMatch(request.headers.get("x-vercel-signature"), expected)) {
    return reply(401, { error: "invalid_signature" });
  }

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

  if (event.type !== "deployment.promoted") return reply(204);

  const projectId = event.payload?.project?.id;
  const deploymentId = event.payload?.deployment?.id;
  const deliveryId = event.id;
  if (!projectId || !deploymentId || !deliveryId) {
    return reply(400, { error: "missing_event_identity" });
  }
  if (projectId !== VERCEL_PROJECT_ID) return reply(204);

  const eventKey = `vercel-promoted-${createHash("sha256")
    .update(String(deliveryId))
    .digest("hex")
    .slice(0, 32)}`;
  const payload = {
    title: "Vercel production deployed",
    message: `${PINGROOM_PROJECT_LABEL} is serving production. Check Vercel before acting.`,
  };

  try {
    const response = await fetch(PINGROOM_WEBHOOK_URL, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        "Idempotency-Key": eventKey,
      },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(8000),
    });

    if (!response.ok) return reply(502, { error: "pingroom_rejected" });
    const accepted = await response.json().catch(() => null);
    if (accepted?.success !== true) {
      return reply(502, { error: "pingroom_invalid_response" });
    }
    return reply(200, { ok: true });
  } catch {
    return reply(502, { error: "pingroom_unavailable" });
  }
}
```

The handler forwards no field from Vercel's payload. `PINGROOM_PROJECT_LABEL` is a human-reviewed server setting, not source metadata. If even the project label is sensitive, use a generic value such as **Selected project**.

Vercel's event ID becomes an opaque, bounded PingRoom idempotency key. If Vercel retries the same delivery after losing a response, PingRoom can return the original accepted result instead of creating another Ping.

## Add the Vercel account webhook

Vercel's [account-webhook setup](https://vercel.com/docs/webhooks) is available to Pro and Enterprise teams:

1. Select the team scope, then open **Settings → Webhooks**.
2. Choose **Deployment Promoted** as the only event for this endpoint.
3. Scope the webhook to the intended project instead of every team project.
4. Enter the public HTTPS URL for your relay.
5. Create the webhook and save its secret immediately as `VERCEL_WEBHOOK_SECRET`; Vercel does not show it again.
6. Copy the project's ID from its settings into `VERCEL_PROJECT_ID`.

The relay checks the project again even though Vercel scopes the webhook. That second boundary keeps a later dashboard change from silently broadening the destination.

`deployment.promoted` means Vercel has started serving that production deployment. It does not prove that every external dependency is healthy, every cache is warm, every client can reach the site, or every phone displayed the Ping. Keep Vercel as the deployment record and keep any required release verification in the release process.

## Give retries one safe outcome

Vercel retries non-2xx webhook deliveries with exponential backoff for up to 24 hours. The relay therefore returns a non-2xx response when PingRoom rejects or cannot accept the handoff.

| Relay outcome                        | HTTP result | What Vercel does                         |
| ------------------------------------ | ----------: | ---------------------------------------- |
| Valid promotion accepted by PingRoom |         200 | Delivery is complete                     |
| Valid but unrelated event or project |         204 | Delivery is complete; no Ping is created |
| Invalid signature or malformed event |     400/401 | Delivery remains visibly unsuccessful    |
| PingRoom rejects or times out        |         502 | Vercel can retry                         |

If configuration stays broken, disable the Vercel webhook or repair the relay before the retry window ends. Do not let a retired Room or expired PingRoom Pro plan produce a day of pointless attempts.

![Two PingRoom avatars compare one accepted deployment token with an identical retry stopped outside the verification gate while Pingo watches beside a closed ledger.](/blog/vercel-production-deployment-push-notifications-1.webp)

_These AI-generated editorial illustrations feature approved PingRoom avatars and Pingo the Pigeon, PingRoom's fictional Chief Marketing Officer. They are not Vercel or PingRoom screenshots and do not depict customers._

[Pingo the Pigeon](/Pingo) is giving the stopped duplicate his usual look. He cannot access Vercel, inspect a private deployment, enter a Room, hold credentials, or send a Ping. The team chooses the project, event, destination, lock-screen copy, and retry policy.

## Test the boundary before a real release

Use locally signed fixtures first, then a harmless production promotion for a non-sensitive test project:

1. Sign a `deployment.promoted` fixture and confirm one fixed Ping is accepted.
2. Replay the exact event ID and confirm the Room still has one accepted Ping for that delivery.
3. Change the project ID and confirm the relay returns `204` without calling PingRoom.
4. Alter the body after signing and confirm the relay returns `401`.
5. Disable the PingRoom webhook and confirm the relay returns non-2xx so the failure stays visible.
6. Check the Ping on a locked test phone. Its title and body should reveal no source, commit, author, URL, alias, team, region, or deployment metadata.

For this article, we imported the exact handler above and ran it against locally signed Vercel-style fixtures plus a stateful mock of PingRoom's documented idempotency contract. No live Vercel team, deployment, PingRoom Room, phone, or network request was used.

If one Vercel production promotion deserves a shared signal, [create the destination Room and webhook](/app/rooms), deploy the relay, and run the six checks with non-sensitive data. Use the [broader CI/CD guide](/blog/ci-cd-deployment-notifications) to decide which other release states genuinely need attention.

_Vercel is a trademark of Vercel Inc. PingRoom is not affiliated with or endorsed by Vercel._
