---
title: "Cảnh báo thanh toán thất bại trên Stripe: tranh chấp, từ chối và webhook chết"
description: "Stripe báo bằng email khi có tranh chấp hay thanh toán thất bại. Cách biến sự kiện webhook Stripe thành push, cảnh báo khẩn hoặc cuộc gọi."
date: 2026-09-11
author: Nooc
authorAvatarLink: /images/avatars/nooc.webp
authorLink: https://nooc.me
tags:
  - Stripe
  - thanh toán
  - webhook
  - tranh chấp
  - SaaS
  - cảnh báo
---

# Cảnh báo thanh toán thất bại trên Stripe: tranh chấp, từ chối và webhook chết

Stripe đã biết khi nào một tranh chấp được mở, khi nào việc thu tiền gói đăng ký thất bại và khi nào một khoản chi trả bị trả về. Nó báo cho bạn bằng email. Muốn thứ gì đó ồn ào hơn, hãy đăng ký thêm một endpoint webhook trong Stripe trỏ tới URL kênh Echobell, đăng ký vài loại sự kiện, rồi chọn kiểu thông báo tương ứng với thời hạn gắn với từng loại. Tranh chấp và cảnh báo gian lận sớm có đồng hồ đang chạy; một lần gia hạn thất bại thì thường không.

Hướng dẫn này nói về những sự kiện Stripe nào xứng đáng cắt ngang bạn, cách trỏ Stripe sang Echobell trong khoảng năm phút, những trường mẫu thực sự tồn tại trên từng đối tượng, và cái giá bạn chấp nhận khi bỏ qua việc xác minh chữ ký.

## Những sự kiện thực sự xứng đáng cắt ngang bạn

Cảnh báo thanh toán luôn hỏng theo cùng một kiểu: ai đó đăng ký `payment_intent.succeeded` vì nó cho cảm giác dễ chịu, điện thoại rung bốn mươi lần mỗi ngày, và sáu tuần sau một thông báo tranh chấp trôi qua mà không ai đọc. Hãy bắt đầu từ thời hạn. Nếu bỏ lỡ sự kiện trong tám giờ chẳng tốn gì, thì nó không cần đến tay bạn trong tám giây.

| Sự kiện | Vì sao quan trọng | Kiểu đề xuất |
| --- | --- | --- |
| `charge.dispute.created` | Bạn có một khoảng thời gian hạn chế để phản hồi — [thường 7 đến 21 ngày tùy mạng lưới thẻ](https://docs.stripe.com/disputes/responding). Bỏ lỡ là thua tự động. | Cuộc gọi |
| `radar.early_fraud_warning.created` | Ngân hàng phát hành đã báo với Stripe rằng một giao dịch có thể là gian lận. Hoàn tiền trước khi nó thành tranh chấp chính thức là hành động vẫn còn khả dụng, và cửa sổ ấy rất ngắn. | Cuộc gọi |
| `payout.failed` | Tiền Stripe đã thu không tới được ngân hàng của bạn. Mọi thứ phía sau — trả lương, tính đường băng tài chính — giờ đều sai. | Cuộc gọi |
| `invoice.payment_failed` | Rời bỏ ngoài ý muốn. Nó tự giải quyết đủ thường xuyên để một cuộc gọi là quá mức, nhưng các tài khoản lớn nhất đáng được nhìn ngay trong ngày. | Khẩn |
| `customer.subscription.deleted` | Rời bỏ tự nguyện. Đáng biết hôm nay, không đáng để đánh thức bạn. | Thường |
| `payment_intent.succeeded` | Chẳng có gì hỏng cả. Chính sự kiện này huấn luyện bạn phớt lờ năm cái còn lại. | Không cần |

Các mức này tương ứng với ba [kiểu thông báo](/vi/docs/notification) của Echobell: **Thường** là push bình thường, **Khẩn** xuyên qua hầu hết chế độ tập trung, còn **Cuộc gọi** hiện lên như một cuộc gọi đến nên vẫn đổ chuông qua Không làm phiền. Mỗi người đăng ký chọn mức riêng cho từng kênh, nên người đồng sáng lập có thể nhận tranh chấp bằng cuộc gọi trong khi đồng nghiệp hỗ trợ nhận bằng push.

## Bạn cần gì

- Một tài khoản Stripe có quyền vào [tab Webhooks trong Workbench](https://dashboard.stripe.com/webhooks)
- Đã cài Echobell ([App Store](https://apps.apple.com/app/apple-store/id6743597198?pt=128151925&ct=blog-stripe-payment-failure-alerts-vi&mt=8) / [Google Play](https://play.google.com/store/apps/details?id=one.echobell.echobellandroid))
- Năm phút. Không máy chủ, không triển khai, không code — trừ khi bạn muốn xác minh chữ ký, phần đó nằm ở mục cuối.

## Bước 1 — Mỗi loại sự kiện một kênh

Rất dễ nảy ra ý định làm một kênh duy nhất tên "Stripe" rồi ném hết vào đó. Đừng. Mẫu nội dung và liên kết dashboard khác nhau giữa tranh chấp, hóa đơn và khoản chi trả, vì mỗi thứ mang một đối tượng khác nhau — và toàn bộ ý nghĩa của cách bố trí này là thông báo tự nói cho bạn biết chuyện gì đã xảy ra mà không phải mở gì cả.

Tạo kênh đặt tên theo sự kiện: `Stripe Disputes`. Viết mẫu tiêu đề và nội dung sao cho đọc gọn ngay trên màn hình khóa:

```
Title: 🔴 Dispute opened — {{data.object.reason}}
Body: Amount: {{data.object.amount}} {{data.object.currency}}
Charge: {{data.object.charge}}
Status: {{data.object.status}}
```

Đặt mẫu liên kết trong cài đặt nâng cao để bản ghi thông báo mở đúng trang:

```
https://dashboard.stripe.com/disputes/{{data.object.id}}
```

Rồi tự đăng ký với kiểu **Cuộc gọi**, và sao chép URL webhook từ màn hình chi tiết kênh. Nó trông như `https://hook.echobell.one/t/<channel-token>`.

<Callout type="info">Bật **POST Only** trong cài đặt nâng cao của kênh. Stripe luôn gửi POST, và công tắc này khiến việc dán URL vào cửa sổ chat hay thanh địa chỉ trình duyệt không còn kích hoạt được một cảnh báo tranh chấp giả.</Callout>

## Bước 2 — Trỏ Stripe tới kênh

Trong dashboard Stripe, mở [tab Webhooks](https://dashboard.stripe.com/webhooks) và tạo một event destination:

<Steps>

<Step>

Bấm **Create an event destination**, chọn **Your account**, và để nguyên phiên bản API mặc định của tài khoản.

</Step>

<Step>

Chọn đúng một loại sự kiện — `charge.dispute.created` cho kênh này. Chính Stripe cũng khuyên [chỉ đăng ký những sự kiện mà tích hợp của bạn cần](https://docs.stripe.com/webhooks); ở đây việc đó còn giữ cho mẫu trung thực, vì mọi payload đến đều cùng một hình dạng.

</Step>

<Step>

Chọn **Webhook endpoint** làm kiểu đích rồi dán URL kênh Echobell.

</Step>

<Step>

Lưu lại, rồi dùng **Send test event** — hoặc `stripe trigger charge.dispute.created` từ CLI — và xác nhận điện thoại đổ chuông.

</Step>

</Steps>

Lặp lại cho từng kênh bạn đã tạo. Stripe cho phép tối đa 16 endpoint webhook mỗi tài khoản, thừa đủ để mỗi mức cảnh báo một cái.

## Bước 3 — Thứ thực sự đến nơi

Stripe gửi [đối tượng Event](https://docs.stripe.com/api/events/object) dưới dạng JSON. Echobell đọc phần thân nguyên vẹn, nên mọi trường đều truy cập được trong mẫu và điều kiện bằng ký pháp chấm:

```json
{
  "id": "evt_1P...",
  "type": "charge.dispute.created",
  "livemode": true,
  "created": 1757548800,
  "data": {
    "object": {
      "id": "dp_1P...",
      "amount": 4900,
      "currency": "usd",
      "reason": "fraudulent",
      "status": "needs_response",
      "charge": "ch_3P...",
      "evidence_details": { "due_by": 1759449600 }
    }
  }
}
```

Ba điều ở payload này thường gây bất ngờ:

**Số tiền là số nguyên theo đơn vị tiền tệ nhỏ nhất.** `amount` bằng `4900` nghĩa là $49.00. Mẫu của Echobell nội suy và so sánh giá trị nhưng không làm số học, nên `{{data.object.amount}}` hiển thị `4900`. Hoặc ghi nhãn cho trung thực (`Amount: 4900 (cents)`), hoặc dùng bộ chuyển tiếp ở mục cuối để chia cho 100 trước khi gửi.

**Dấu thời gian là giây Unix.** `{{data.object.evidence_details.due_by}}` hiện ra là `1759449600`, không phải một ngày. Nếu điều quan trọng là có thời hạn chứ không phải giờ chính xác, hãy bỏ nó khỏi mẫu — trang tranh chấp có hiển thị — và để mẫu liên kết làm việc.

**Tên trường khác nhau theo đối tượng.** Tranh chấp có `amount`; hóa đơn có `amount_due`, `customer_email`, `attempt_count` và `hosted_invoice_url`; khoản chi trả có `failure_message` và `arrival_date`; cảnh báo gian lận sớm có `fraud_type`, `actionable`, và `charge` chỉ là một chuỗi ID. Biến không tồn tại sẽ hiển thị thành chuỗi rỗng chứ không báo lỗi, nên một mẫu sao chép nhầm kênh sẽ hỏng trong im lặng. Đây chính là lý do thực tế của nguyên tắc mỗi loại sự kiện một kênh.

## Bước 4 — Lọc bằng điều kiện, không bằng ý chí

[Điều kiện](/vi/docs/conditions) của kênh dùng cùng cú pháp biểu thức với mẫu, chỉ bỏ ngoặc nhọn, và chạy trước khi bất cứ thứ gì được gửi đi.

Điều kiện nên có ở mọi kênh Stripe:

```
livemode == true
```

Lưu lượng chế độ thử — những lần `stripe trigger` của chính bạn, một đồng nghiệp nghịch trong sandbox — không còn tới điện thoại bạn nữa. Hãy thêm nó sau khi đã xác nhận đường dây chạy được, không phải trước.

Với kênh hóa đơn thất bại, một ngưỡng giữ những tài khoản nhỏ ở ngoài buổi tối của bạn:

```
livemode == true && data.object.amount_due > 20000
```

Đọc là "trên $200", tính bằng xu. Còn nếu bạn muốn thấy những lần thử lại thực sự tắc thay vì mọi lần thất bại đầu tiên:

```
livemode == true && data.object.attempt_count > 1
```

Nếu bạn lỡ trỏ một endpoint với nhiều loại sự kiện vào một kênh, điều kiện sẽ tách chúng ra lại:

```
type == "charge.dispute.created" || type == "payout.failed"
```

## Bước 5 — Giữ đường cảnh báo nằm ngoài thứ sẽ hỏng

Đây là phần đáng giá hơn các mẫu.

Endpoint webhook chạy thật của bạn là nơi việc giao hàng diễn ra: nó cấp quyền truy cập, ghi vào cơ sở dữ liệu, gửi biên nhận. Vì thế nó cũng chính là endpoint sập khi ứng dụng của bạn sập. Khi đó, Stripe [thử lại tối đa ba ngày với khoảng lùi theo cấp số nhân](https://docs.stripe.com/webhooks) và gửi cho bạn một email — mà email về webhook chưa giao được trông y hệt mọi email Stripe khác, nên nó thường được tìm thấy vào thứ Hai.

Nguyên nhân webhook chết lặng lẽ đều rất nhàm. Stripe coi chuyển hướng `3xx` là thất bại, nên một endpoint bắt đầu chuyển `http` sang `https` hoặc thêm dấu gạch chéo cuối sẽ ngừng nhận sự kiện. Nó đòi TLS 1.2 trở lên, nên một chứng chỉ hết hạn hoặc cấu hình sai là đủ. Một `403` từ luật WAF ai đó thêm tuần trước cũng vậy.

Một endpoint thứ hai trỏ thẳng vào Echobell không chia sẻ bất cứ điều nào trong số đó. Nó là URL khác trên máy chủ khác với chứng chỉ khác, và nó vẫn đổ chuông trong lúc ứng dụng của bạn nằm im. Quy tắc này khái quát được: *đường báo cho bạn biết có thứ hỏng thì không nên chạy qua chính thứ đang hỏng.*

Bạn vẫn muốn thấy được sự cố của endpoint của mình. Hãy xem tab **Event deliveries** trong Workbench khi thấy có gì đó lấn cấn — nó hiển thị `Delivered`, `Pending` và `Failed` theo từng sự kiện, kèm mã HTTP của mỗi lần thử. Stripe cho gửi lại một sự kiện trong vòng 15 ngày từ dashboard, hoặc 30 ngày với `stripe events resend` từ CLI, nên một lỗ hổng phát hiện trong hai tuần vẫn vá được.

<Callout type="warn">URL kênh Echobell là một thông tin xác thực dạng bearer: ai giữ nó đều có thể kích hoạt kênh. Trỏ Stripe thẳng vào đó nghĩa là không ai xác minh header `Stripe-Signature`, nên một URL bị lộ là cỗ máy cảnh báo giả, không phải rò rỉ dữ liệu. Đừng để nó trong kho mã và ảnh chụp màn hình, dùng **Reset Token** nếu nó lọt ra ngoài, và đọc mục sau nếu đánh đổi này làm bạn khó chịu.</Callout>

## Tùy chọn — Xác minh chữ ký trước

Nếu bạn muốn chữ ký Stripe thực sự được kiểm tra và số tiền được định dạng như tiền, hãy đặt một bộ chuyển tiếp nhỏ ở phía trước. Cloudflare Worker sau xác minh sự kiện, trả `200` ngay như Stripe yêu cầu, rồi gửi cho Echobell một payload phẳng:

```js
import Stripe from "stripe";

export default {
  async fetch(request, env, ctx) {
    const stripe = new Stripe(env.STRIPE_SECRET_KEY);
    const body = await request.text();

    let event;
    try {
      event = await stripe.webhooks.constructEventAsync(
        body,
        request.headers.get("stripe-signature"),
        env.STRIPE_WEBHOOK_SECRET,
      );
    } catch {
      return new Response("invalid signature", { status: 400 });
    }

    const invoice = event.data.object;
    ctx.waitUntil(
      fetch(env.ECHOBELL_HOOK_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          customer: invoice.customer_email || invoice.customer,
          amount: (invoice.amount_due / 100).toFixed(2),
          currency: invoice.currency.toUpperCase(),
          attempt: invoice.attempt_count,
          externalLink: invoice.hosted_invoice_url,
        }),
      }),
    );

    return new Response("ok", { status: 200 });
  },
};
```

Mẫu ở đầu bên kia đẹp hơn hẳn, vì việc định hình đã làm xong trong code:

```
Title: 💳 Payment failed — {{currency}} {{amount}}
Body: Customer: {{customer}}
Attempt #{{attempt}}
```

`externalLink` là biến đặc biệt: khi không đặt mẫu liên kết, Echobell dùng nó làm liên kết cho bản ghi thông báo, nên trang hóa đơn được host chỉ cách một cú chạm.

Hãy để ý hình dạng của sự đánh đổi: đây giờ là một mảnh hạ tầng có thể tự hỏng, đúng thứ mà Bước 5 cảnh báo. Một dung hòa hợp lý là xác minh chữ ký ở kênh khối lượng lớn, nơi cảnh báo giả sẽ gây phiền, và để kênh tranh chấp nối thẳng — ở đó cái giá của một hồi chuông vô cớ là một cái nhìn ngơ ngác, còn cái giá của một hồi chuông bị bỏ lỡ là số tiền bị tranh chấp.

## Những gì cách này không cho bạn

- **Không có lịch trực hay leo thang.** Tất cả người đăng ký kênh cuộc gọi đều đổ chuông cùng lúc. Với bốn người đó là ưu điểm, với bốn mươi người là vấn đề; ở mức bốn mươi bạn cần một nền tảng quản lý sự cố.
- **Không khử trùng lặp.** Stripe [không bảo đảm thứ tự sự kiện](https://docs.stripe.com/webhooks) và có thể gửi cùng một sự kiện nhiều lần. Hai hồi chuông cho một tranh chấp là chuyện có thể xảy ra.
- **Không có xác nhận đã đọc.** Không gì ghi lại rằng có người đã thấy, và không gì chuyển sang người thứ hai nếu chẳng ai phản hồi.
- **Không có cảnh báo "thanh toán đã dừng".** Stripe phát sự kiện khi có chuyện xảy ra, chứ không bao giờ khi mọi thứ ngừng lại. Nếu trang thanh toán của bạn hỏng, sẽ không có sự kiện nào được kích hoạt. Cái đó cần một tác vụ định kỳ ở phía bạn, ping một kênh khi số giao dịch trong giờ vừa rồi bằng không — một [công tắc người chết dựa trên cron](/vi/blog/cron-job-failure-alerts).

## Khắc phục sự cố

**Sự kiện thử hiện `200` trong Stripe nhưng không có thông báo nào.** Echobell trả `200` kèm thân JSON ngay cả khi không gửi gì — hãy xem phần thân phản hồi trong tab Event deliveries. `success: false` với token đúng độ dài nghĩa là token kênh sai. Nếu `success` là `true`, nguyên nhân có thể là điều kiện: `livemode == true` chặn mọi sự kiện thử, đúng như thiết kế.

**Stripe báo `405 Method Not Allowed`.** Kênh bật POST Only mà thứ gì đó gửi GET. Bản thân Stripe luôn POST, nên đó là bản xem trước liên kết hoặc một tab trình duyệt, không phải Stripe.

**Thông báo đến với các trường trống.** Mẫu đang trỏ nhầm đối tượng — `{{data.object.amount}}` ở kênh hóa đơn, nơi trường đó tên là `amount_due`. Hãy gửi một sự kiện thật, mở nó trong dashboard và đọc JSON.

**Việc gửi bắt đầu thất bại sau nhiều tuần chạy tốt.** Kiểm tra chứng chỉ và mọi chuyển hướng đặt trước URL. Với URL kênh dùng trực tiếp thì chuyện này hiếm; với bộ chuyển tiếp bạn tự triển khai, đó là nghi phạm quen thuộc.

## Câu hỏi thường gặp

### Stripe có gọi điện cho tôi khi mở tranh chấp không?

Tự nó thì không. Stripe báo qua email, trên dashboard, qua sự kiện `charge.dispute.created`, và qua push nếu bạn dùng ứng dụng Stripe Dashboard. Muốn chuông reo thật, hãy định tuyến sự kiện đó tới một kênh có kiểu đăng ký là **Cuộc gọi**.

### Tôi có phải viết code để nối Stripe với Echobell không?

Không. Stripe gửi JSON tới bất kỳ URL HTTPS công khai nào, và URL kênh Echobell chính là như vậy. Chỉ cần code khi bạn muốn xác minh header `Stripe-Signature` hoặc định dạng lại số tiền.

### Đưa cho Stripe một URL webhook của bên thứ ba có an toàn không?

Đó là một đánh đổi có chủ ý. Payload Stripe gửi chứa siêu dữ liệu khách hàng và thanh toán, còn Echobell không lưu vĩnh viễn payload webhook thô — thông báo đã dựng nằm trên thiết bị của bạn. Thứ bạn từ bỏ là việc xác minh chữ ký: ai biết URL đều có thể gửi cho bạn một bản giả rất thuyết phục. Hãy coi nó như khóa API, và dùng mô hình chuyển tiếp cho những gì bạn muốn xác minh.

### Vì sao cảnh báo hiện 4900 thay vì $49.00?

Stripe gửi số tiền dưới dạng số nguyên theo đơn vị tiền tệ nhỏ nhất, và mẫu Echobell không làm số học. Hãy ghi đơn vị trong mẫu, hoặc chia cho 100 trong bộ chuyển tiếp trước khi gửi.

### Làm sao để sự kiện chế độ thử không đánh thức tôi?

Thêm điều kiện `livemode == true` vào kênh. Stripe đánh dấu mọi sự kiện sandbox và `stripe trigger` là `livemode: false`.

### Người đồng sáng lập có nhận được cùng cảnh báo mà không phải trả tiền chỗ ngồi không?

Có. Chia sẻ liên kết kênh; mỗi người đăng ký chọn kiểu thông báo riêng. Một người nhận tranh chấp bằng cuộc gọi trong khi người kia nhận bằng push thường, và không có phí theo chỗ ngồi cho người đăng ký.

### Có nên đặt cảnh báo cho thanh toán thành công?

Chỉ trong thời gian ngắn, và chỉ khi việc kinh doanh còn đủ nhỏ để mỗi giao dịch vẫn là một sự kiện. Ngay khi thông báo thanh toán thành công trở thành thường lệ, nó bắt đầu bào mòn phản ứng của bạn với những cái thực sự quan trọng — cơ chế cốt lõi phía sau [mệt mỏi vì cảnh báo](/vi/blog/fix-alert-fatigue-developer-guide).

## Kết lại

Toàn bộ cách bố trí là một event destination của Stripe cho mỗi kênh Echobell, một điều kiện `livemode == true`, và kỷ luật dành riêng mức cuộc gọi cho những sự kiện có đồng hồ đang chạy. Tranh chấp và cảnh báo gian lận sớm thì có. Một lần gia hạn thất bại của gói $9 thì không, và giả vờ ngược lại chính là cách bạn ngủ quên đúng lúc cái có đồng hồ xuất hiện.

[Tải Echobell cho iPhone](https://apps.apple.com/app/apple-store/id6743597198?pt=128151925&ct=blog-stripe-payment-failure-alerts-vi&mt=8) hoặc [lấy trên Google Play](https://play.google.com/store/apps/details?id=one.echobell.echobellandroid), tạo kênh tranh chấp trước, và chạy một lần `stripe trigger charge.dispute.created` trước khi giao cho đường này bất cứ thứ gì thật sự quan trọng.

---

## Liên quan

- [Tài liệu tích hợp webhook](/vi/docs/webhook)
- [Tham chiếu điều kiện kênh](/vi/docs/conditions)
- [Hướng dẫn cho lập trình viên khắc phục mệt mỏi vì cảnh báo](/vi/blog/fix-alert-fatigue-developer-guide)
- [Cảnh báo cron job thất bại](/vi/blog/cron-job-failure-alerts)
- [Thông báo webhook Zapier về điện thoại](/vi/blog/zapier-webhook-notifications-to-phone)
