How to Send Netlify Production Deploy Alerts to Your Team's Phones
Verify Netlify's signed Deploy succeeded webhook, keep deploy details out of the alert, and send one fixed Ping to a shared release Room.

To send one Netlify production deploy alert to a shared group of phones, add a Deploy succeeded HTTP Post Request in Netlify, verify its JWS signature in a small relay, and forward fixed copy to a PingRoom incoming webhook. Filter one site, production context, and ready state. Use the Netlify deploy ID as the retry identity, and keep commit, branch, URL, author, and log details in Netlify.
This is a webhook recipe, not a native Netlify integration. The destination Room owner needs PingRoom Pro. Netlify's current guide labels email deploy notifications as Pro or Enterprise, but it does not place the same plan note beside HTTP Post Request. Confirm that HTTP Post Request appears in the target project's notification menu before building the relay.
Check Netlify's own notifications first
Netlify already supports deploy information in its dashboard, Slack app, email, and connected Git provider. Start there when the people who need the update already use one of those surfaces.
| Need | Better starting point |
|---|---|
| One project member needs normal deploy details | Netlify dashboard or email |
| A team already follows releases in Slack | Netlify App for Slack |
| A pull request needs deploy status or a preview link | Netlify's Git-provider notification |
| One production success should reach a separate bounded group of phones | Netlify HTTP Post Request → relay → PingRoom Room |
| People need logs, commit details, rollback controls, or an audit history | Open the deploy in Netlify |
| A release needs paging, escalation, or an immutable operational record | Keep the established release or incident system |
This guide uses Deploy succeeded, not every build transition. Netlify defines that event as finishing the upload of a new deploy. The relay then requires the deploy object to say context: "production" and state: "ready" before it sends anything to PingRoom.
Create the destination Room
Create or choose a private PingRoom Room for the people who need this release signal. Open Settings → Connections → From another app, add a webhook named Netlify production, choose Custom message, and copy the full URL.
The incoming-webhook setup guide explains the Room-side steps. The webhook reference lists the current request limits, idempotency behavior, and errors.
Store these values in protected server configuration:
NETLIFY_JWS_SECRET: the secret you enter when creating Netlify's HTTP Post Request;NETLIFY_SITE_ID: the one site allowed to alert this Room;PINGROOM_WEBHOOK_URL: the complete secret PingRoom webhook URL;PINGROOM_PROJECT_LABEL: a short, human-reviewed label such asWebsite.
Do not put either secret in browser code, source control, screenshots, or logs.
Verify the raw body before parsing
Netlify's deploy-notification guide can sign an HTTP notification with HS256 JSON Web Signature. The X-Webhook-Signature claims include issuer netlify and a SHA-256 digest of the raw request body.
The relay must verify three things before it trusts the JSON: the JWS signature, the issuer, and the raw-body digest. The following Web-standard handler uses only Node's built-in crypto module.
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 safeEqual(actual, expected) { return actual.length === expected.length && timingSafeEqual(actual, expected); } function verifyNetlifyJws(token, rawBody, secret) { try { const parts = token?.split("."); if (parts?.length !== 3) return false; const header = JSON.parse( Buffer.from(parts[0], "base64url").toString("utf8"), ); const claims = JSON.parse( Buffer.from(parts[1], "base64url").toString("utf8"), ); if (header.alg !== "HS256" || claims.iss !== "netlify") return false; const expectedSignature = createHmac("sha256", secret) .update(`${parts[0]}.${parts[1]}`) .digest(); const actualSignature = Buffer.from(parts[2], "base64url"); if (!safeEqual(actualSignature, expectedSignature)) return false; const actualDigest = String(claims.sha256 ?? ""); if (!/^[a-f0-9]{64}$/i.test(actualDigest)) return false; const expectedDigest = createHash("sha256").update(rawBody).digest("hex"); return safeEqual( Buffer.from(actualDigest, "hex"), Buffer.from(expectedDigest, "hex"), ); } catch { return false; } } export async function handleNetlifyDeploy(request, env = process.env) { if (request.method !== "POST") return reply(405, { error: "method_not_allowed" }); const { NETLIFY_JWS_SECRET, NETLIFY_SITE_ID, PINGROOM_WEBHOOK_URL, PINGROOM_PROJECT_LABEL, } = env; if ( !NETLIFY_JWS_SECRET || !NETLIFY_SITE_ID || !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" }); } if ( !verifyNetlifyJws( request.headers.get("x-webhook-signature"), rawBody, NETLIFY_JWS_SECRET, ) ) { return reply(401, { error: "invalid_signature" }); } let deploy; try { deploy = JSON.parse(rawBody); } catch { return reply(400, { error: "invalid_json" }); } if (!deploy.id || !deploy.site_id) { return reply(400, { error: "missing_deploy_identity" }); } if (deploy.site_id !== NETLIFY_SITE_ID) return reply(204); if (deploy.context !== "production" || deploy.state !== "ready") { return reply(204); } const eventKey = `netlify-deploy-${createHash("sha256") .update(String(deploy.id)) .digest("hex") .slice(0, 32)}`; const payload = { title: "Netlify production deployed", message: `${PINGROOM_PROJECT_LABEL} finished a production deploy. Check Netlify 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 Netlify's deploy object. PINGROOM_PROJECT_LABEL is a reviewed server setting. If the project name is sensitive, use a generic label such as Selected site.
Add the Netlify notification
In the intended Netlify project:
- Open Project configuration → Notifications → Deploy notifications.
- Add HTTP Post Request for Deploy succeeded.
- Enter the public HTTPS URL for the relay.
- Enter a strong JWS secret and save the same value as
NETLIFY_JWS_SECRETin the relay. - Copy the site's ID from Netlify into
NETLIFY_SITE_ID. - Run one harmless production deploy and inspect the relay result before relying on the workflow.
Netlify sends a JSON representation of the deploy object. That object can include the site name, branch, commit, committer, URLs, error text, and other details. The relay deliberately ignores them.
state: "ready" and context: "production" mean the selected production deploy reached Netlify's ready state. They do not prove that every dependency is healthy, every cache is warm, every visitor can reach the site, or every phone displayed the Ping. Keep Netlify as the deployment record and keep release checks in the release process.
Give duplicate deliveries one outcome
The deploy ID identifies the Netlify deploy. The relay hashes it into a bounded PingRoom Idempotency-Key, so an exact duplicate reaches the same accepted event instead of creating another Ping.
Netlify's current deploy-notification page does not document a retry schedule for HTTP Post Requests. Do not promise that a non-2xx response will be retried. The relay still returns non-2xx when PingRoom rejects or cannot accept the handoff, because reporting success would hide the failure.
| Relay outcome | HTTP result | Meaning |
|---|---|---|
| Signed selected production deploy accepted | 200 | PingRoom accepted the bounded event |
| Signed preview, non-ready state, or another site | 204 | No Ping is needed |
| Invalid JWS or altered body | 401 | Sender identity is not trusted |
| Missing deploy identity or malformed JSON | 400 | Payload cannot be mapped safely |
| PingRoom rejects, times out, or returns bad JSON | 502 | Handoff failed and remains visible to the relay |

These AI-generated editorial illustrations feature approved PingRoom avatars and Pingo the Pigeon, PingRoom's fictional Chief Marketing Officer. They are not Netlify or PingRoom screenshots and do not depict customers.
Pingo the Pigeon is only watching the duplicate stop at the gate. He has no Netlify account, private-Room access, credentials, or authority to send a Ping. The human team chooses the project, event, destination, copy, and response procedure.
Test the boundary before a real release
Use locally signed fixtures first, then one harmless production deploy for a non-sensitive test project:
- Sign a
readyproduction fixture and confirm one fixed Ping is accepted. - Replay the same deploy ID and confirm PingRoom keeps one accepted event for that deploy.
- Change
site_idand confirm the relay returns204without calling PingRoom. - Change
contexttodeploy-previewand confirm no Ping is created. - Alter the body after signing and confirm the relay returns
401. - Disable the PingRoom webhook and confirm the relay reports a non-2xx failure.
- Check the Ping on a locked test phone. Its title and body should reveal no site name, branch, commit, author, URL, log, or error detail.
For this article, we imported the exact handler above and ran it against locally signed Netlify-style fixtures plus a stateful mock of PingRoom's documented idempotency contract. No live Netlify project, deploy, PingRoom Room, phone, or network request was used.
If one Netlify production success deserves a shared signal, create the destination Room and webhook, deploy the relay, and run the seven checks with non-sensitive data. Use the broader CI/CD guide to decide whether any other release state genuinely needs attention.
Netlify is a trademark of Netlify, Inc. PingRoom is not affiliated with or endorsed by Netlify.
PingRoom
Short-form communication.


