How to Send Stripe Payout Updates to a Shared Team Room
Verify Stripe’s payout.paid webhook, forward fixed privacy-minimal copy, and keep Stripe and the destination bank authoritative.

To send a Stripe payout update to a small team’s phones, receive the payout.paid snapshot event at a server-side relay, verify Stripe’s signature against the unmodified request body, and forward fixed privacy-minimal copy to a PingRoom incoming webhook. Use the Stripe event ID as PingRoom’s idempotency key so an exact retry does not create another Ping.
Keep Stripe and the destination bank authoritative. Stripe defines payout.paid as the point when a payout is expected to be available in the destination account; Stripe can still send payout.failed later. The Ping should say Stripe payout marked paid, not Money arrived.
This is a webhook recipe, not a native Stripe integration or proof that a bank credited funds.
Decide whether a shared alert helps
Stripe’s Dashboard and webhook-delivery history already own payout details. Add a shared Ping only when a small, stable group needs the same low-context attention signal outside Stripe.
| Need | Best owner |
|---|---|
| View amount, currency, destination, trace ID, timing, or reconciliation | Stripe Dashboard and reports |
| Investigate money that is not visible at the bank | Stripe’s payout support process and the bank |
| Tell a bounded team that Stripe marked one live payout paid | Verified relay → private PingRoom Room |
| Trigger accounting or ledger changes | Your approved finance system and controls |
Use fixed phone copy:
- Title: Stripe payout marked paid
- Message: Check Stripe before reconciling
Do not copy the amount, currency, payout ID, connected-account ID, recipient, bank name, bank-account details, trace ID, or Dashboard URL into a lock-screen preview.
If Stripe’s own email or an existing finance workflow already reaches the right people, use it. Another alert is useful only when it changes who notices the event.
Prepare one account-scoped event destination
You need:
- a Stripe snapshot webhook destination for your own account, subscribed only to
payout.paid; - its
whsec_…signing secret in protected server-side configuration; - a private PingRoom Room for the people who handle payout follow-up; and
- PingRoom Pro, which includes incoming webhooks.
In PingRoom, open the Room and add an incoming webhook under Settings → Connections → From another app. Save the complete URL in protected server configuration as PINGROOM_WEBHOOK_URL. Its final path segment is a credential.
Save Stripe’s endpoint secret separately as STRIPE_WEBHOOK_SECRET. This relay does not need a Stripe API key. Neither secret belongs in browser code, a mobile app, source control, logs, analytics, or the Ping.
The general webhook guide covers the Room-side setup. PingRoom’s webhook reference lists current fields, limits, idempotency behavior, cooldown, and errors.
Verify the raw body before reading the event
The dependency-free Node handler below makes the signature boundary visible. An official Stripe SDK’s webhook helper is the better default in an application that already uses that SDK. Either way, verify the exact raw bytes before parsing JSON.
import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 300; function verifyStripeSignature(rawBody, header, secret, nowSeconds) { const parts = new Map(); for (const item of (header || "").split(",")) { const [key, value] = item.split("=", 2); if (!parts.has(key)) parts.set(key, []); parts.get(key).push(value); } const timestamp = Number(parts.get("t")?.[0]); const signatures = parts.get("v1") || []; if (!Number.isSafeInteger(timestamp) || signatures.length === 0) return false; if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false; const expected = createHmac("sha256", secret) .update(`${timestamp}.`) .update(rawBody) .digest(); return signatures.some((signature) => { if (!/^[0-9a-f]{64}$/i.test(signature || "")) return false; const supplied = Buffer.from(signature, "hex"); return ( supplied.length === expected.length && timingSafeEqual(supplied, expected) ); }); } export async function handleStripePayoutWebhook(request, options = {}) { const stripeSecret = options.stripeSecret || process.env.STRIPE_WEBHOOK_SECRET; const pingroomUrl = options.pingroomUrl || process.env.PINGROOM_WEBHOOK_URL; const fetchImpl = options.fetchImpl || fetch; const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000); if (!stripeSecret || !pingroomUrl) { return Response.json({ error: "missing_configuration" }, { status: 500 }); } const rawBody = Buffer.from(await request.arrayBuffer()); const signature = request.headers.get("stripe-signature"); if (!verifyStripeSignature(rawBody, signature, stripeSecret, nowSeconds)) { return Response.json({ error: "invalid_signature" }, { status: 400 }); } let event; try { event = JSON.parse(rawBody.toString("utf8")); } catch { return Response.json({ error: "invalid_json" }, { status: 400 }); } if (event?.type !== "payout.paid") { return Response.json({ received: true, forwarded: false }); } if ( typeof event.id !== "string" || !event.id.startsWith("evt_") || event.livemode !== true || event.data?.object?.object !== "payout" || event.data.object.status !== "paid" ) { return Response.json({ error: "invalid_event" }, { status: 400 }); } const pingBody = JSON.stringify({ action: null, title: "Stripe payout marked paid", message: "Check Stripe before reconciling", correlation_id: event.id, }); let upstream; try { upstream = await fetchImpl(pingroomUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", "Idempotency-Key": `stripe-${event.id}`, }, body: pingBody, signal: AbortSignal.timeout(8000), }); } catch { return Response.json({ error: "pingroom_unavailable" }, { status: 503 }); } if (!upstream.ok) { return Response.json( { error: "pingroom_rejected", upstream_status: upstream.status }, { status: 502 }, ); } return Response.json({ received: true, forwarded: true }); }
Adapt the exported handler to your framework’s raw-body route. Do not place a JSON body parser in front of it. Stripe’s webhook guide documents the raw-body requirement, endpoint signing secret, snapshot-event setup, and quick 2xx response requirement.
For a busy production system, verify the event, write its ID and fixed message intent to a durable queue, return 2xx, and let a worker call PingRoom. The compact synchronous version is easier to inspect, but it waits for PingRoom and is not a throughput design.
Why the handler checks more than the event name
The handler accepts only a live-mode snapshot event whose object is a payout with status: "paid".
| Check | Purpose |
|---|---|
Valid Stripe-Signature over the raw body | Reject altered or unauthenticated requests |
| Fresh timestamp | Limit replay of an old signed body |
Exact payout.paid type | Keep unrelated Stripe events quiet |
livemode: true | Prevent a sandbox rehearsal from reaching the live team Room |
Payout object with status: "paid" | Reject malformed or mismatched snapshots |
| Stable Stripe event ID | Give exact retries one PingRoom identity |
Do not infer bank credit, reconciliation, or finality from the event name. Stripe’s event reference says payout.paid means expected availability, and a later payout.failed is possible. For connected-account payout questions, Stripe’s missing-payout guidance also distinguishes a paid Stripe status from confirmation that the recipient bank has credited the funds.

Pingo the Pigeon, PingRoom’s fictional Chief Marketing Officer, has been assigned the closed ledger. He cannot open Stripe, inspect a bank account, enter a private Room, or send a Ping. The people in the workflow verify every financial state.
These AI-generated editorial illustrations use approved Pingo and PingRoom avatar references. The people and tabletop workflow are illustrative, not customers or evidence of a payout.
Test eight boundaries before live traffic
Use a Stripe sandbox or Stripe CLI fixture and a separate non-production PingRoom Room. Keep sandbox and live endpoint secrets separate.
- A correctly signed live
payout.paidfixture produces one fixed-copy Ping. - The exact same event replays without a second Ping.
- A signed test-mode event is rejected and sends nothing.
- A signed payout whose object is not
paidis rejected. - A signed unrelated Stripe event is acknowledged and ignored.
- A bad signature is rejected.
- A signature older than five minutes is rejected.
- A disabled PingRoom webhook surfaces a non-
2xxresult.
We ran those eight cases against the exact handler above with locally signed Stripe-shaped fixtures and a stateful PingRoom mock. Twenty-seven assertions passed. The mock also checked that payout ID, account ID, amount, currency, and destination type never entered the Ping. No live Stripe account, payout, PingRoom Room, bank account, network request, or phone delivery was used.
Stripe’s CLI signing secret differs from the secret for a deployed event destination. A passing fixture verifies this relay’s boundaries; it does not verify a real payout lifecycle or your finance controls.
Define what happens after the Ping
The fixed update should lead to an agreed, non-urgent procedure:
- Open the Stripe Dashboard through the team’s normal access path.
- Confirm the current payout state and account scope.
- Wait for the team’s normal bank and reconciliation evidence.
- Use Stripe’s documented support path if a payout remains missing.
- Record the result in the finance system that owns it.
Do not add amount or recipient details to make the Ping feel more complete. The point is to move attention to the authoritative record without copying financial data onto shared lock screens.
PingRoom’s incoming webhook accepts one new Ping every five seconds by default and can return HTTP 429. An exact same-key replay is resolved before that cooldown because it does not create a new Ping. If several distinct payout events can arrive together, queue them and decide whether the team needs each event, a digest, or no phone alert at all.
A successful PingRoom response means the Ping was accepted and entered PingRoom’s normal delivery path. What a phone displays still depends on connectivity, operating-system policy, notification permission, Focus settings, and Room mutes.
When the sandbox rehearsal passes, open PingRoom, create a private payout-update Room, and connect only the event your team is prepared to verify.
The two editorial illustrations were created with AI using approved PingRoom character references and reviewed by the PingRoom team.
PingRoom
Short-form communication.


