# How to Send Stripe Payment-Failure Alerts to Your Team's Phones

> Verify Stripe's invoice.payment_failed webhook in a small relay, then send fixed privacy-minimal copy to one shared PingRoom Room.

- Published: 2026-10-23
- Author: PingRoom
- Category: Developers
- Topics: Stripe payment failed notification, Stripe invoice.payment_failed webhook, Stripe webhook phone alert
- Canonical: https://pingroom.io/blog/stripe-payment-failed-push-notifications
- Cover image: https://pingroom.io/blog/stripe-payment-failed-push-notifications-cover.webp
- Cover description: Two PingRoom avatars and Pingo watch one plain terracotta token pass through a brass verification gate into a shared tray.

To send a Stripe subscription payment-failure alert to a team’s phones, receive **`invoice.payment_failed`** 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 a retry does not create another Ping.

Keep Stripe and your billing application as the source of truth. The Ping tells the team to review the billing dashboard; it does not retry a payment, contact a customer, change a subscription, or prove that somebody acted.

This is a webhook recipe, not a native Stripe integration.

## Decide what belongs in the alert

Stripe’s [`invoice.payment_failed` event](https://docs.stripe.com/api/events/types#event_types-invoice.payment_failed) applies when an invoice payment attempt fails. For a subscription workflow, Stripe’s [subscription webhook guide](https://docs.stripe.com/billing/subscriptions/webhooks) explains the billing actions your application may need to take, including customer notification, payment-method recovery, and retry configuration.

Those actions belong in the billing system. The shared phone alert should stay deliberately small:

| Question                                                       | Owner                                       |
| -------------------------------------------------------------- | ------------------------------------------- |
| Did Stripe emit a verified invoice-payment failure?            | The relay’s verified event record           |
| What customer, invoice, amount, or payment method is involved? | Stripe and your approved billing tools      |
| Should access, retries, or customer communication change?      | Your billing application and team procedure |
| Who needs attention now?                                       | The private PingRoom Room                   |

Use a fixed title such as **Stripe invoice payment failed** and a fixed message such as **Review the billing dashboard**. Do not copy a customer name, email address, invoice ID, amount, decline reason, payment link, or card detail into the notification preview.

If Stripe’s own email, Dashboard, or an existing incident system already reaches the right people reliably, use that instead of adding another alert.

## Prepare the two webhook secrets

You need:

- a Stripe webhook destination subscribed only to `invoice.payment_failed`;
- its `whsec_…` endpoint signing secret, held in protected server-side configuration;
- a private PingRoom Room for the people responsible for billing 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**. Keep the complete URL in protected server configuration as `PINGROOM_WEBHOOK_URL`. The final path segment is a credential.

Store Stripe’s endpoint secret separately as `STRIPE_WEBHOOK_SECRET`. A Stripe API secret key is not needed for this relay, and neither secret belongs in client code, source control, logs, or the Ping itself.

The [general incoming-webhook guide](/blog/webhook-to-push-notification) covers the Room-side setup. The [webhook reference](/documents/webhooks) lists PingRoom’s current fields, limits, idempotency behavior, cooldown, and errors.

## Verify first, then forward fixed copy

The relay below uses Node’s built-in cryptography so the signature check is visible. If your stack has an official Stripe SDK, Stripe recommends its webhook helper. In either case, verification must use the exact raw bytes that Stripe sent—not parsed and re-serialized 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 handleStripeWebhook(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 || typeof event !== "object") {
    return Response.json({ error: "invalid_event" }, { status: 400 });
  }

  if (event.type !== "invoice.payment_failed") {
    return Response.json({ received: true, forwarded: false });
  }
  if (typeof event.id !== "string" || !event.id.startsWith("evt_")) {
    return Response.json({ error: "invalid_event" }, { status: 400 });
  }

  const pingBody = JSON.stringify({
    action: null,
    title: "Stripe invoice payment failed",
    message: "Review the billing dashboard",
    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 put a JSON body parser in front of it. Stripe’s [webhook implementation guide](https://docs.stripe.com/webhooks) documents the raw-body requirement, recommends returning a successful response quickly, and explains live delivery retries.

For a busy production system, verify the event, write its ID to a durable queue, return `2xx`, and let a worker call PingRoom. The compact synchronous relay above is easier to inspect, but Stripe waits for the PingRoom request to finish. Its eight-second timeout is a bound, not a throughput design.

## What the replay protection does

Stripe can deliver the same event more than once and does not guarantee delivery order. The relay therefore uses the event ID in two places:

- `correlation_id` keeps the Ping tied to the source event without exposing invoice data; and
- `Idempotency-Key: stripe-${event.id}` lets an exact PingRoom retry replay the original response instead of creating another Ping.

Do not use the event creation time to infer order or uniqueness. If your billing application changes access or sends customer communication, it still needs its own durable event record and current subscription-state checks. This Room alert is not that record.

![Two PingRoom avatars compare an accepted event token with a stopped duplicate while Pingo guards a brass divider.](/blog/stripe-payment-failed-push-notifications-1.webp)

_[Pingo the Pigeon](/Pingo), our fictional Chief Marketing Officer, is watching the duplicate while the closed secret capsule stays on the table. He has not opened Stripe, read a customer invoice, accessed the Room, or sent the Ping._

_AI-generated editorial artwork uses approved Pingo and PingRoom avatar references. The people and workflow are illustrative, not a customer story._

## Test five paths before live traffic

Use a Stripe sandbox or Stripe CLI fixture and a non-production PingRoom Room. Then verify:

1. A correctly signed `invoice.payment_failed` event produces one Ping with only the fixed title and message.
2. The same signed event sent again returns success without a second Ping.
3. A correctly signed unrelated event returns `received: true` and `forwarded: false`.
4. A bad or stale signature returns HTTP `400` and sends nothing.
5. A disabled or invalid PingRoom webhook makes the relay return a non-`2xx` response, so the failed forwarding attempt remains visible for repair and retry.

The code above was checked locally with signed fixtures and a stateful PingRoom mock. It accepted the first failure event once, replayed the same event without a second Ping, ignored another event type, rejected bad and stale signatures, and surfaced an upstream rejection. No live Stripe account, customer, subscription, PingRoom Room, or phone delivery was used.

Stripe’s CLI can forward sandbox events to a local route. Its generated signing secret is different from the secret for your deployed destination, so keep each environment’s secret separate. Do not treat a generated fixture as proof that your full billing lifecycle is correct.

## Define the team response outside the notification

The fixed alert should lead to an agreed procedure:

1. Open Stripe or the approved billing console.
2. Confirm the event and current invoice and subscription state.
3. Follow the existing customer-notification, retry, access, and escalation policy.
4. Record the outcome in the system that owns billing work.

Stripe may make automatic retry and recovery features available for subscription payments. Configure those in Stripe according to your billing policy; the PingRoom relay should not attempt to reproduce them.

PingRoom’s incoming webhook accepts one new Ping every five seconds by default and can return HTTP `429`. An exact same-key replay is returned before that cooldown because it creates no new Ping. If several distinct payment failures can arrive in a burst, put them in a durable queue and batch or rate-limit team alerts instead of turning every event into a phone interruption.

A successful PingRoom response means the event was accepted and queued through PingRoom’s normal delivery path. What a phone displays still depends on connectivity, operating-system policy, notification permission, Focus settings, and Room mutes. Keep the billing dashboard, webhook-delivery history, and normal escalation path.

When the sandbox tests pass, [open PingRoom](/app/rooms), create a private billing-alert Room, and connect only the failure signal that your team is prepared to handle.

_The two editorial illustrations were created with AI using approved PingRoom character references and reviewed by the PingRoom team._
