# API và webhook cho FDE: viết tích hợp như thể mọi thứ sẽ được gửi hai lần

> Lỗi đắt nhất của một tích hợp doanh nghiệp thường không làm sập hệ thống: nó lặng lẽ trừ tiền khách hai lần, hoặc bỏ sót một sự kiện mà không ai hay.

Bản gốc: https://fdetimes.net/vi/bach-khoa/api-va-tich-hop/

Chín giờ sáng thứ Hai, kế toán của khách hàng gửi cho bạn một ảnh chụp màn hình: cùng một đơn hàng bị trừ tiền hai lần. Log cho thấy cuối tuần mạng chập chờn một lúc, request tạo thanh toán bị timeout, và job của bạn đã ngoan ngoãn retry đúng như được dạy.

Code không sai cú pháp và cũng không crash. Cái sai nằm ở giả định rằng một request chỉ đến đúng một lần, theo đúng thứ tự, và luôn có câu trả lời rõ ràng. Với một Forward Deployed Engineer, giả định ấy hỏng gần như mỗi ngày.

Palantir mô tả vai trò FDE là đưa kỹ sư vào làm việc ngay cạnh khách hàng để giải các bài toán cấp bách nhất của họ. Làm việc bên trong hệ thống của khách cũng có nghĩa là sớm muộn bạn sẽ phải chạm vào những đường ống nối hệ thống ấy với các hệ thống khác. Nếu viết đường ống ấy cho chắc, bạn được tin tưởng.

Nếu viết ẩu, bạn sẽ mất cả tuần để giải thích vì sao số liệu lệch.

## Khi nhận về lỗi, bạn không biết điều gì đã xảy ra

Hãy bắt đầu bằng một sự thật khó chịu. Tài liệu xử lý lỗi của Stripe nói thẳng: khi gặp lỗi mạng, client không biết server đã nhận request hay chưa. Một response 500 cho thao tác ghi cũng phải được coi là kết quả chưa xác định, tức thao tác có thể đã chạy, cũng có thể chưa.

Vì thế retry mù là nguy hiểm, còn không retry thì có thể bỏ sót đơn hàng. Lối ra là idempotency key: một chuỗi do client tự sinh, dài tối đa 255 ký tự, và Stripe gợi ý dùng UUID V4. Bạn gắn chuỗi này vào request để server nhận ra những lần gửi lại.

Cơ chế của Stripe khá đơn giản. Họ lưu status code và body của request đầu tiên ứng với mỗi key, kể cả khi request đó thất bại, rồi trả lại đúng kết quả ấy cho mọi lần retry. Nếu bạn dùng lại một key nhưng đổi tham số, lớp idempotency sẽ báo lỗi để chặn việc dùng nhầm.

Có ba chi tiết người mới hay bỏ qua. Key có thể bị xoá sau 24 giờ, nên đừng coi nó là bản ghi vĩnh viễn. Mọi POST đều nhận key, còn gửi key cho GET và DELETE thì không có tác dụng gì vì hai phương thức này vốn đã idempotent.

Và lời khuyên của Stripe sau lỗi mạng là retry với cùng key, cùng tham số, cho tới khi nhận được kết quả từ server.

## Một job tạo thanh toán viết lại cho đúng

Quay lại sự cố sáng thứ Hai. Thay đổi quan trọng nhất là key phải được sinh **một lần** khi tạo đơn và lưu cùng đơn, không sinh lại trong mỗi lần retry. Nếu sinh lại, mỗi lần retry trông như một giao dịch mới và idempotency mất tác dụng.

```python
def create_payment(order):
key = order.idempotency_key          # UUID v4, sinh khi tạo đơn, lưu trong DB
payload = {
"amount": order.amount,
"currency": order.currency,
"metadata[order_id]": order.id,  # để đối soát về sau
}
return send_with_retry(payload, key, order)
```

Vòng retry tách riêng, và mọi lần gửi đều dùng đúng key ấy:

```python
def send_with_retry(payload, key, order):
for attempt in range(6):
try:
r = requests.post(PAYMENTS_URL, data=payload,
headers={"Idempotency-Key": key}, timeout=10)
except (requests.ConnectionError, requests.Timeout):
time.sleep(2 ** attempt)     # retry cùng key
continue
if r.status_code == 429:
time.sleep(retry_after(r, attempt))
continue
if r.status_code >= 500:
mark_indeterminate(order)    # chờ webhook/đối soát
return None
return r
mark_indeterminate(order)
return None
```

Nhánh 429 đáng để ý. Giới hạn tốc độ là chuyện thường ngày khi tích hợp, và theo MDN, response 429 có thể kèm header Retry-After cho biết cần chờ bao lâu trước khi gửi request mới. Server đã cho con số thì cứ chờ đúng như vậy, đừng tự đoán:

```python
def retry_after(r, attempt):
wait = r.headers.get("Retry-After")
return int(wait) if wait and wait.isdigit() else 2 ** attempt
```

Nhánh 500 thì không retry mù. Đơn được đánh dấu "chưa xác định", và sự thật sẽ đến từ phía bên kia: webhook, cộng với metadata `order_id` để khớp giao dịch với đơn. Đó cũng là cách Stripe khuyên xử lý: đối soát qua webhook và metadata.

## Webhook: đến muộn, đến hai lần, đến sai thứ tự

Chiều ngược lại khó hơn, vì bạn không kiểm soát thời điểm người khác gọi mình. Stripe nói rõ một endpoint có thể thỉnh thoảng nhận cùng một event nhiều lần, và event có thể đến không theo thứ tự. Crossmint ghi trong tài liệu rằng webhook của họ đảm bảo giao "ít nhất một lần", nghĩa là phía nhận buộc phải idempotent.

Một handler tốt làm bốn việc theo đúng trình tự sau.

```python
@app.post("/webhooks/payments")
def receive():
raw = request.get_data()                       # body thô, chưa parse
sig = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(raw, sig, WEBHOOK_SECRET)
except Exception:
return "", 400                             # chữ ký sai hoặc quá cũ
if not processed_events.insert_if_absent(event["id"]):
return "", 200                             # đã thấy rồi: bỏ qua
queue.enqueue(handle_event, event["id"])
return "", 200                                 # trả 2xx ngay
```

Bước đầu tiên là lấy body thô. Stripe ký mọi webhook bằng HMAC-SHA256 qua header `Stripe-Signature`, và việc xác minh cần đúng body gốc. Nếu framework đã parse rồi serialize lại, chỉ một khoảng trắng thay đổi cũng đủ làm chữ ký không khớp, và bạn sẽ mất nửa ngày nghi ngờ secret.

Chữ ký còn bao gồm một timestamp để chống tấn công replay, và thư viện của Stripe mặc định chấp nhận lệch tối đa 5 phút giữa timestamp đó và giờ hiện tại. Hệ quả thực tế là nếu đồng hồ server của khách lệch nhiều, mọi webhook sẽ bị từ chối dù secret hoàn toàn đúng.

Tiếp theo là khử trùng lặp theo event ID, rồi trả 2xx trước khi làm việc nặng. Stripe khuyên xử lý event qua hàng đợi bất đồng bộ để chịu được những đợt tăng đột biến. Trong worker, đừng tin rằng event "đã thanh toán" luôn đến sau event "đã tạo". An toàn hơn là đọc trạng thái hiện tại của đối tượng rồi mới cập nhật đơn.

**Điểm mấu chốt:** Hãy coi mỗi request là thứ có thể chạy hai lần, và mỗi event là thứ có thể đến sai thứ tự; code đúng trong cả hai trường hợp thì mới đem vào production.

## Mỗi nhà cung cấp hứa một kiểu, và bạn phải đọc kỹ lời hứa

Đây là chỗ FDE kiếm được lòng tin: đọc tài liệu của từng bên đủ kỹ để biết khi hệ thống của mình sập thì ai chịu trách nhiệm gửi lại.

| | Stripe | GitHub |
|---|---|---|
| Thời gian phải phản hồi | Trả 2xx trước khi xử lý logic nặng | Phải trả 2XX trong 10 giây |
| Khi giao thất bại | Live mode tự gửi lại đến ba ngày, với exponential backoff | Không tự động gửi lại |
| Việc của bạn sau sự cố | Chịu được event cũ dồn về cùng lúc | Tự redeliver các webhook bị lỡ khi server hoạt động lại |

Khác biệt này quyết định kiến trúc. Với Stripe, một sự cố hai tiếng thường tự lành, miễn handler chịu được lượng event dồn về. Với GitHub, nếu không có script hay quy trình redeliver, dữ liệu bị lỡ sẽ mất hẳn, và runbook bàn giao cho khách phải ghi rõ bước đó.

## Những lỗi gặp đi gặp lại

Lỗi phổ biến nhất là sinh idempotency key mới trong vòng retry, khiến cơ chế bảo vệ vô dụng. Gần đó là dùng lại key cũ nhưng đổi tham số, chẳng hạn sửa số tiền, rồi ngạc nhiên vì bị báo lỗi. Muốn đổi tham số thì đó là một thao tác mới và cần key mới.

Lỗi thứ hai là coi 500 như "thất bại chắc chắn" rồi tạo lại giao dịch bằng key khác. Lỗi thứ ba là làm toàn bộ logic nghiệp vụ ngay trong handler webhook: ghi DB, gọi ERP, gửi email. Một lần ERP chậm là quá timeout, nhà cung cấp coi như giao thất bại, và vòng gửi lại bắt đầu.

Lỗi cuối cùng ít người nói tới: không có đường đối soát. Dù code tốt đến đâu, bạn vẫn cần một job định kỳ so trạng thái hai bên qua metadata, vì thế nào cũng có ngày một event bị lỡ.

## Đưa kỹ năng này vào CV

Khi đọc JD của các vị trí FDE hay solutions engineer, hãy để ý những cụm như "integrations", "webhooks", "customer systems", "data pipelines". Gặp những cụm đó, bạn nên chuẩn bị sẵn câu chuyện về idempotency và webhook. Hãy cho thấy bạn từng xử lý lỗi thật, không chỉ từng gọi API.

Vì vậy, thay vì viết "Tích hợp Stripe", hãy viết cụ thể: thêm idempotency key và đối soát qua webhook để loại bỏ giao dịch trùng; chuyển webhook handler sang xử lý qua hàng đợi với khử trùng lặp theo event ID. Nếu được hỏi trong phỏng vấn, kể lại sự cố, nguyên nhân và cách bạn chứng minh nó không tái diễn.

Bài tập tuần này: lấy một tích hợp bạn đang chạy, rút dây mạng giữa chừng một POST, rồi gửi lại cùng một webhook ba lần. Nếu dữ liệu vẫn đúng sau cả hai thử nghiệm, bạn đã làm được phần việc mà khách hàng sẽ không bao giờ thấy, và đó chính là phần quyết định họ có tin bạn hay không.

**Thử ngay tuần này:**

- Mở Stripe test mode, gửi cùng một POST hai lần với cùng Idempotency-Key, rồi lần thứ ba với cùng key nhưng đổi amount, và ghi lại ba response.
- Dựng một webhook endpoint nhỏ có xác minh chữ ký, bảng processed_events và hàng đợi, rồi tự gửi lại cùng một event ba lần để chắc chắn chỉ xử lý một lần.
- Mở lại một tích hợp bạn từng viết và kiểm tra: nếu POST gặp timeout thì code của bạn làm gì?

## Nguồn

- [Idempotent requests (Stripe API Reference)](https://docs.stripe.com/api/idempotent_requests)

- [Advanced error handling (Stripe Docs)](https://docs.stripe.com/error-low-level)

- [Receive Stripe events in your webhook endpoint (Stripe Docs)](https://docs.stripe.com/webhooks)

- [Best practices for using webhooks (GitHub Docs)](https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks)

- [Best Practices (Crossmint Docs, Webhooks)](https://docs.crossmint.com/introduction/platform/webhooks/best-practices)

- [Retry-After header - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After)

- [Palantir Technologies - Forward Deployed Software Engineer](https://jobs.lever.co/palantir/5168e8fd-fec1-4fea-b7a1-81bdaea65850)
