# 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.

- Published: 2026-11-03
- Author: PingRoom
- Category: Developers
- Topics: Stripe payout paid notification, Stripe payout.paid webhook, Stripe webhook phone alert
- Canonical: https://pingroom.io/blog/stripe-payout-paid-push-notifications
- Cover image: https://pingroom.io/blog/stripe-payout-paid-push-notifications-cover.webp
- Cover description: Two PingRoom avatars verify a plain terracotta payout marker at a brass gate while Pingo watches beside a closed ledger.

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](/features/incoming-webhook).

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](/blog/webhook-to-push-notification) covers the Room-side setup. PingRoom’s [webhook reference](/documents/webhooks) 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.

```js
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](https://docs.stripe.com/webhooks) 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](https://support.stripe.com/questions/troubleshooting-missing-payouts-for-connected-accounts) also distinguishes a paid Stripe status from confirmation that the recipient bank has credited the funds.

![Two PingRoom avatars compare one accepted terracotta marker with an empty ivory tray while Pingo guards a closed brass capsule.](/blog/stripe-payout-paid-push-notifications-1.webp)

_[Pingo the Pigeon](/Pingo), 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.

1. A correctly signed live `payout.paid` fixture produces one fixed-copy Ping.
2. The exact same event replays without a second Ping.
3. A signed test-mode event is rejected and sends nothing.
4. A signed payout whose object is not `paid` is rejected.
5. A signed unrelated Stripe event is acknowledged and ignored.
6. A bad signature is rejected.
7. A signature older than five minutes is rejected.
8. A disabled PingRoom webhook surfaces a non-`2xx` result.

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:

1. Open the Stripe Dashboard through the team’s normal access path.
2. Confirm the current payout state and account scope.
3. Wait for the team’s normal bank and reconciliation evidence.
4. Use Stripe’s documented support path if a payout remains missing.
5. 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](/app/rooms), 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._
