How to Send Automatic Stripe Dispute Status Updates
Verify Stripe's dispute events, re-read the current status, and send one privacy-minimal team Ping only when that status actually changes.

To send automatic status update notifications for Stripe disputes, accept Stripe's signed dispute events at a server-side relay, queue a sync job, and re-read the dispute from Stripe before deciding what changed. Serialize jobs for the same dispute, compare the authoritative status with the last status you recorded, and send one fixed, privacy-minimal Ping only for a real transition.
Stripe stays the system of record. The Ping should tell a small response group that the case needs review; it should not contain evidence, customer details, an amount, a deadline, or a guess about the outcome.
This is a webhook recipe for card-payment disputes, not a native Stripe integration and not a recipe for Stripe Issuing disputes.
Start with Stripe's own notifications
Stripe already surfaces disputes in the Dashboard and can send account emails. If those reach the right people, stop there. A custom relay adds code, secrets, a queue, and another delivery path to operate.
For teams that already coordinate in Slack, Stripe's Marketplace lists a Slack connector, and automation tools offer Stripe-to-Slack recipes. Those are sensible choices when Slack is already the team's shared attention surface.
Use a PingRoom relay when a small group wants a deliberately sparse signal in one private Room without copying the dispute into chat:
| Need | Smaller choice |
|---|---|
| One account owner needs the normal dispute notice | Stripe Dashboard and email |
| The team already handles disputes in Slack | A reviewed Stripe or automation-tool Slack connection |
| A small response group needs one shared, phone-first status signal | The bounded relay below |
Define the signal before writing code
Stripe's dispute object exposes several statuses. Do not turn all of them into bespoke prose. Map them to a small reviewed vocabulary:
| Stripe status | Shared-Room title | Fixed message |
|---|---|---|
warning_needs_response, needs_response, appeal_needs_response | Stripe dispute needs response | Open Stripe to review the current case. |
warning_under_review, under_review, appeal_under_review | Stripe dispute is under review | Open Stripe to review the current case. |
prevented | Stripe dispute was prevented | Open Stripe to review the current case. |
warning_closed | Stripe inquiry closed | Open Stripe to review the current case. |
won | Stripe dispute closed: won | Open Stripe to review the current case. |
lost | Stripe dispute closed: lost | Open Stripe to review the current case. |
Keep the dispute ID, customer, email, charge, payment method, amount, reason, evidence, network deadline, and evidence links in Stripe. A notification preview can appear on a locked screen.
The status is a prompt to inspect Stripe, not proof that somebody responded. Your dispute procedure still needs a named owner and whatever escalation path the business has approved.
Prepare Stripe, the relay, and one private Room
You need:
- a Stripe event destination subscribed to
charge.dispute.created,charge.dispute.updated, andcharge.dispute.closed; - its
whsec_…signing secret in protected server configuration; - a restricted Stripe API key that can retrieve the current dispute;
- a durable queue plus a store that can serialize work by dispute ID and persist the last signaled status; and
- a private PingRoom Room with PingRoom Pro incoming webhooks.
In PingRoom, open the Room and add an incoming webhook under Settings → Connections → From another app. Store the full secret URL as PINGROOM_WEBHOOK_URL; its final path segment is a credential. PingRoom's webhook reference documents the request fields, idempotency behavior, cooldown, and current limits.
Stripe's webhook guide requires signature verification against the exact raw request body. Do not let a JSON body parser alter the bytes first.
Verify and queue the source event
The HTTP handler should do very little: verify the signature, allowlist the three event types, extract the dispute ID, and durably enqueue a sync job once per Stripe event ID. Return 2xx only after that queue write succeeds.
import { createHmac, timingSafeEqual } from "node:crypto"; const ALLOWED_TYPES = new Set([ "charge.dispute.created", "charge.dispute.updated", "charge.dispute.closed", ]); const TOLERANCE_SECONDS = 300; function verifyStripeSignature(rawBody, header, secret, nowSeconds) { const fields = new Map(); for (const item of (header || "").split(",")) { const [key, value] = item.split("=", 2); if (!fields.has(key)) fields.set(key, []); fields.get(key).push(value); } const timestamp = Number(fields.get("t")?.[0]); const signatures = fields.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 acceptStripeDisputeEvent(request, options) { const rawBody = Buffer.from(await request.arrayBuffer()); const valid = verifyStripeSignature( rawBody, request.headers.get("stripe-signature"), options.stripeWebhookSecret, options.nowSeconds ?? Math.floor(Date.now() / 1000), ); if (!valid) 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 (!ALLOWED_TYPES.has(event.type)) { return Response.json({ received: true, queued: false }); } const disputeId = event.data?.object?.id; if ( typeof event.id !== "string" || !event.id.startsWith("evt_") || typeof disputeId !== "string" || !disputeId.startsWith("dp_") ) { return Response.json({ error: "invalid_event" }, { status: 400 }); } // Contract: atomically deduplicate event.id and durably enqueue this job. await options.enqueueEventOnce(event.id, { type: "sync_stripe_dispute", disputeId, }); return Response.json({ received: true, queued: true }); }
Use Stripe's official SDK helper if it fits your stack. The important invariants are the unmodified bytes, the endpoint-specific secret, the timestamp tolerance, and a durable queue write before success.
Re-read Stripe inside a per-dispute lane
Stripe can retry events, and webhook delivery order is not guaranteed. Do not infer the current state from whether the event name says created, updated, or closed. The sync worker below serializes work for one dispute, retrieves the current object from Stripe, and compares the status with the last one it committed.
import { createHash } from "node:crypto"; const COPY = { warning_needs_response: [ "Stripe dispute needs response", "Open Stripe to review the current case.", ], needs_response: [ "Stripe dispute needs response", "Open Stripe to review the current case.", ], appeal_needs_response: [ "Stripe dispute needs response", "Open Stripe to review the current case.", ], warning_under_review: [ "Stripe dispute is under review", "Open Stripe to review the current case.", ], under_review: [ "Stripe dispute is under review", "Open Stripe to review the current case.", ], appeal_under_review: [ "Stripe dispute is under review", "Open Stripe to review the current case.", ], prevented: [ "Stripe dispute was prevented", "Open Stripe to review the current case.", ], warning_closed: [ "Stripe inquiry closed", "Open Stripe to review the current case.", ], won: [ "Stripe dispute closed: won", "Open Stripe to review the current case.", ], lost: [ "Stripe dispute closed: lost", "Open Stripe to review the current case.", ], }; export async function syncStripeDispute(disputeId, options) { return options.withDisputeLock(disputeId, async (state) => { const dispute = await options.fetchCurrentDispute(disputeId); const copy = COPY[dispute.status]; if (!copy) return { changed: false, reason: "unmapped_status" }; if (state.lastSignaledStatus === dispute.status) { return { changed: false, reason: "same_status" }; } const opaqueKey = createHash("sha256") .update(`stripe-dispute:${disputeId}:${dispute.status}`) .digest("hex"); // Contract: update state and commit this outbox job in the same transaction. await state.commitStatusAndPing(dispute.status, { title: copy[0], message: copy[1], correlation_id: opaqueKey, idempotencyKey: `stripe-dispute-${opaqueKey}`, }); return { changed: true, status: dispute.status }; }); }
withDisputeLock is an application boundary, not a Stripe or PingRoom API. Implement it with a database transaction, advisory lock, or keyed serial queue appropriate to your system. commitStatusAndPing must update the last status and write a Ping outbox job atomically. A worker then POSTs that fixed body to PingRoom with the stored idempotencyKey as its Idempotency-Key header.
This two-stage design matters. If an old updated delivery arrives after a newer one, both sync jobs read Stripe's current object. The per-dispute lane prevents two workers from racing their comparison, and the stored status suppresses a second Ping for the same current state.

Pingo the Pigeon, our fictional Chief Marketing Officer, is watching the older token stop at the divider. He has not opened Stripe, accessed the Room, read a dispute, or sent a Ping.
AI-generated editorial artwork uses approved Pingo and PingRoom avatar references. The people and token workflow are illustrative, not a customer story.
Rehearse the transitions, not just one happy path
Test the complete chain in Stripe's sandbox and a non-production PingRoom Room:
- A valid
charge.dispute.createddelivery queues one sync and produces one mapped Ping. - Replaying the same event ID does not create another queue job.
- A different dispute event that re-reads the same current status creates no second Ping.
- A later authoritative status produces exactly one new outbox job.
- An older event delivered after that change re-reads the current status and produces no stale Ping.
- A bad or stale signature queues nothing.
- An unsupported event type is acknowledged but ignored.
- A Stripe retrieval or transactional queue failure remains retryable instead of being reported as success.
- A PingRoom
429or temporary failure retries the same outbox body with the same idempotency key.
The code above was checked locally with signed Stripe-shaped fixtures, a mocked authoritative dispute read, a per-dispute transactional stand-in, and a stateful PingRoom request mock. The tests covered initial state, same-state updates, a real transition, a closed outcome, exact replay, out-of-order delivery, bad and stale signatures, an unrelated event, a Stripe read failure, a queue failure, and a PingRoom retry. No live Stripe account, dispute, customer record, PingRoom Room, phone, or delivery receipt was used.
Interpret acceptance conservatively
A successful Stripe webhook response means the relay durably accepted the source event. A successful PingRoom webhook response means PingRoom accepted the Ping into its normal delivery path. Neither proves that a specific phone displayed it or that a person reviewed the dispute.
Phone display still depends on connectivity, operating-system policy, notification permission, Focus settings, and Room mutes. Keep Stripe's Dashboard, delivery history, native email, and your normal escalation procedure available.
PingRoom incoming webhooks accept one new Ping every five seconds by default. If many disputes can change together, rate-limit or batch the outbox rather than turning every state into an interruption. Keep exact retries on the same idempotency key.
When the sandbox rehearsal passes, open PingRoom, create or choose a private dispute-response Room, and connect only the status transitions your team has agreed to handle.
If your task is an invoice payment attempt failing before any dispute exists, use the separate Stripe payment-failure alert guide. For a generic request contract before adapting this recipe, start with webhook-to-push notification setup.
The two editorial illustrations were created with AI using approved PingRoom character references and reviewed by the PingRoom team.
PingRoom
Short-form communication.


