How to Send a HubSpot Deal-Stage Alert to a Shared Phone Room
Verify HubSpot's signed deal-stage webhook, queue one selected stage change, and forward fixed, replay-safe copy to a PingRoom Room.

To send one selected HubSpot deal-stage change to a shared group of phones, subscribe a small HTTPS relay to the dealstage property, verify HubSpot's v3 signature, filter one portal, app, and target stage, then queue fixed copy for a PingRoom incoming webhook. Use a stable key derived from the stage-change event so HubSpot or queue retries do not become duplicate Pings.
This is a webhook recipe, not a native HubSpot integration. The destination Room's owner needs PingRoom Pro. You also need a HubSpot app, its client secret, permission to install it in the intended account, and a durable queue in the relay runtime.
First, use HubSpot's own notifications when they fit
HubSpot can already send mobile notifications when a deal you own or follow moves to another stage. Its current notification guide notes one important boundary: a deal owner does not receive that notification when they manually change the stage themselves.
| What people need | Better starting point |
|---|---|
| Deal record, amount, owner, history, or next step | HubSpot |
| Stage updates for HubSpot owners or followers | HubSpot's mobile notification settings |
| Internal task or email after a stage change | HubSpot pipeline automation |
| One terse signal for a stable group beyond HubSpot's user list | HubSpot webhook → private relay → PingRoom Room |
| A full sales-process audit, routing system, or escalation policy | Your CRM and operations system |
HubSpot remains the source of truth. The Ping below says only that one selected deal reached the agreed stage. It does not copy the deal name, amount, company, owner, contact data, notes, or record URL to the Room.
Define the signal before connecting anything
Choose one stage that has an obvious shared consequence. Examples include Contract sent for a small delivery group or Closed won for an onboarding Room. Do not send every movement in the pipeline.
Use fixed server-side copy:
HubSpot deal stage changed
Selected HubSpot deal reached Contract sent.
The stage label is configuration, not CRM content. Keep the detailed work in HubSpot. If the group would immediately ask “which deal?”, they probably need HubSpot access or a different workflow—not more customer data on a lock screen.
Create the private Room and protect its URL
Create or choose a private Room for the stable group that needs this signal. Open Settings → Connections → From another app, add an incoming webhook, and store its complete URL as PINGROOM_WEBHOOK_URL in the relay's secret store.
The incoming-webhook walkthrough covers the Room-side setup. The webhook reference carries the current fields, limits, cooldown behavior, idempotency rules, and error responses.
The HubSpot target points to your relay, not directly to PingRoom. HubSpot and PingRoom use different request contracts, and the relay is where sender verification, filtering, privacy reduction, queueing, and replay control happen.
Subscribe only to dealstage
HubSpot's current developer-platform guide defines webhook components under src/app/webhooks/. A compact configuration for the classic deal property-change event looks like this:
{ "uid": "deal-stage-webhook", "type": "webhooks", "config": { "settings": { "targetUrl": "https://relay.example.com/hubspot", "maxConcurrentRequests": 1 }, "subscriptions": { "legacyCrmObjects": [ { "subscriptionType": "deal.propertyChange", "propertyName": "dealstage", "active": true } ] } } }
Deploy the app configuration and install the app in the intended HubSpot account. In HubSpot's pipeline settings, hover over the chosen stage and use the internal-name control to copy its exact value. Store that as HUBSPOT_TARGET_STAGE_ID; keep the human-readable HUBSPOT_STAGE_LABEL separate.
Set these server-side values:
HUBSPOT_APP_ID: the app that owns the subscription;HUBSPOT_PORTAL_ID: the one HubSpot account allowed to create this signal;HUBSPOT_CLIENT_SECRET: the signing secret, never exposed to a browser;HUBSPOT_PUBLIC_WEBHOOK_URL: the exact public URL configured in HubSpot;HUBSPOT_TARGET_STAGE_ID: the selected stage's internal value;HUBSPOT_STAGE_LABEL: approved lock-screen copy such asContract sent;PINGROOM_WEBHOOK_URL: the complete secret incoming-webhook URL.
Verify first, then enqueue
HubSpot's v3 signature uses the HTTP method, public request URI, untouched body, and request timestamp. Reject stale requests, compute HMAC-SHA256 with the client secret, Base64-encode it, and compare it in constant time. Read the raw body before any JSON middleware changes it.
The receiver should then require all of these boundaries:
- the expected HubSpot portal and app;
deal.propertyChangefordealstage;- the exact target stage value;
- numeric subscription, object, and occurrence identifiers;
- an idempotent enqueue keyed by the stage-change identity.
HubSpot sends webhook events in batches. Do not loop over that batch and call the PingRoom URL immediately. Put accepted events in a durable queue with duplicate suppression, then let one consumer send them at a controlled rate. This keeps a legitimate burst from colliding with the Room webhook's cooldown.
The core receiver logic is:
import { createHash, createHmac, timingSafeEqual } from "node:crypto"; const FIVE_MINUTES_MS = 5 * 60 * 1000; function validSignature({ method, uri, body, timestamp, signature, secret }) { if (!/^\d{13}$/.test(timestamp ?? "")) return false; if (Math.abs(Date.now() - Number(timestamp)) > FIVE_MINUTES_MS) return false; const expected = createHmac("sha256", secret) .update(method + uri + body + timestamp, "utf8") .digest("base64"); const left = Buffer.from(expected); const right = Buffer.from(signature ?? ""); return left.length === right.length && timingSafeEqual(left, right); } function eventKey(event) { const source = [ event.portalId, event.appId, event.subscriptionId, event.objectId, event.propertyValue, event.occurredAt, ].join(":"); return `hubspot-stage:${createHash("sha256").update(source).digest("hex")}`; } export async function acceptHubSpotBatch(request, env, queue) { const body = await request.text(); if ( !validSignature({ method: "POST", uri: env.HUBSPOT_PUBLIC_WEBHOOK_URL, body, timestamp: request.headers.get("x-hubspot-request-timestamp"), signature: request.headers.get("x-hubspot-signature-v3"), secret: env.HUBSPOT_CLIENT_SECRET, }) ) { return new Response("invalid signature", { status: 401 }); } const events = JSON.parse(body); if (!Array.isArray(events) || events.length > 100) { return new Response("invalid batch", { status: 400 }); } for (const event of events) { const selected = String(event.portalId) === env.HUBSPOT_PORTAL_ID && String(event.appId) === env.HUBSPOT_APP_ID && event.subscriptionType === "deal.propertyChange" && event.propertyName === "dealstage" && event.propertyValue === env.HUBSPOT_TARGET_STAGE_ID; if (!selected) continue; const key = eventKey(event); await queue.enqueueOnce(key, { eventKey: key }); } return new Response(null, { status: 202 }); }
Use HubSpot's full URI-decoding rules in production when the configured URL contains encoded characters. The tested article receiver implements that rule, validates body size and identifiers, rejects changed content types and malformed batches, and treats queue insertion as an idempotent operation.

Pingo the Pigeon, PingRoom's fictional CMO, is supervising the queue with his usual confidence in meetings. He cannot read CRM data, access a private Room, or send a Ping; people chose the stage and the relay enforces it.
Let one queue consumer create the Ping
The queue job needs only the derived event key. It should not contain the deal object. The consumer posts the fixed title and configured stage label, then uses the same key as PingRoom's Idempotency-Key:
await fetch(env.PINGROOM_WEBHOOK_URL, { method: "POST", redirect: "manual", headers: { "Content-Type": "application/json", "Idempotency-Key": job.eventKey, }, body: JSON.stringify({ action: null, title: "HubSpot deal stage changed", message: `Selected HubSpot deal reached ${env.HUBSPOT_STAGE_LABEL}.`, }), });
Configure the queue for one-at-a-time delivery or a delay that respects the current Room webhook cooldown. Make downstream non-success responses retryable. PingRoom can safely replay an exact request with the same body and idempotency key; reusing that key with changed content is a conflict, not a reason to invent a new key.
Test the boundary, not a real customer's data
Before enabling the subscription for normal work, use a disposable deal with generic values and a test Room:
- A correctly signed event for the selected portal, app, property, and stage queues one job.
- Replaying the same event queues no second job.
- Another stage, portal, app, property, or subscription type creates no Ping.
- A missing, incorrect, stale, or future-dated signature is rejected.
- Malformed JSON, a non-array body, an oversized batch, and the wrong content type are rejected.
- The queue consumer sends only the fixed title and configured stage label.
- A PingRoom redirect, throttle, or other non-success remains a retryable queue failure.
- A locked test phone shows no deal, contact, company, amount, owner, note, or CRM link.
For this article, the exact receiver and consumer passed 12 controlled boundaries with signed HubSpot-shaped fixtures, an idempotent in-memory queue, and a local mock of PingRoom's documented incoming-webhook contract. One unique mock signal was queued and delivered. No live HubSpot account, installed app, deployed queue, PingRoom Room, or phone delivery was tested.
If the audience truly belongs outside HubSpot, create the private Room, deploy the relay with a durable queue, and move one disposable deal through the selected stage before connecting real work.
PingRoom
Short-form communication.


