Xác thực chữ ký, retry, idempotency, thứ tự sự kiệnSignatures, retries, idempotency, event ordering
Bên trái là chiều vào: receiver chỉ kiểm chữ ký, đẩy vào Kafka và trả 200; processor làm việc thật, chống trùng và kiểm trạng thái. Bên phải là chiều ra: dispatcher ký payload, gửi tới khách hàng, thử lại có backoff và chuyển vào DLQ. Bật “Chữ ký giả” và “Provider gửi lại” để xem hai lớp bảo vệ.The left side is inbound: the receiver only checks the signature, queues to Kafka and answers 200; the processor does the real work, deduplicates and checks state. The right side is outbound: the dispatcher signs the payload, sends it to customers, retries with backoff and parks failures in a DLQ. Turn on “Forged signature” and “Provider retries” to see both safeguards.
1Stripe báo payment intent pi_3QxA của đơn 7781 đã thành công.Stripe reports that payment intent pi_3QxA for order 7781 succeeded.
Kiểm HMAC-SHA256 trên raw body đúng từng byte, trước khi parse JSON; framework parse rồi serialize lại sẽ làm chữ ký sai. So sánh bằng MessageDigest.isEqual hoặc hmac.Equal để không lộ thời gian. Giữ hai secret khi xoay khoá.Verify HMAC-SHA256 over the exact raw bytes before parsing JSON; a framework that parses and re-serialises breaks the signature. Compare with MessageDigest.isEqual or hmac.Equal to avoid timing leaks. Keep two secrets valid while rotating.
Timestamp nằm trong phần được ký, receiver từ chối nếu lệch quá 5 phút. Dedup theo event id chặn nốt các bản replay trong cửa sổ đó.The timestamp is part of the signed string and the receiver rejects anything more than 5 minutes off. Dedup by event id stops replays inside that window.
Stripe chờ khoảng 10 giây; quá thời gian là coi như lỗi và gửi lại, tạo thêm tải. Receiver chỉ kiểm chữ ký, ghi Kafka rồi trả 2xx; worker xử lý bất đồng bộ.Stripe waits about 10 seconds; anything slower counts as a failure and is resent, adding load. The receiver only checks the signature, writes to Kafka and answers 2xx; workers do the rest asynchronously.
Provider giao at-least-once. Bảng processed_event có khoá chính (provider, event_id), chèn trong cùng transaction với cập nhật nghiệp vụ; 0 dòng nghĩa là bỏ qua.Providers deliver at least once. processed_event has a primary key of (provider, event_id) and is inserted in the same transaction as the business update; zero rows means skip.
charge.refunded có thể đến trước payment_intent.succeeded. Lưu last_event_at hoặc version trên payment, chỉ cho chuyển trạng thái tiến lên; khi nghi ngờ thì gọi GET /v1/payment_intents/{id} để lấy trạng thái thật thay vì tin payload.charge.refunded can arrive before payment_intent.succeeded. Store last_event_at or a version on the payment and allow only forward transitions; when unsure, call GET /v1/payment_intents/{id} for the real state instead of trusting the payload.
Stripe ký bằng header Stripe-Signature, VNPay ký vnp_SecureHash bằng HMAC-SHA512 trên query string, MoMo ký trường signature. Mỗi provider có một adapter kiểm chữ ký và chuyển về PaymentEvent nội bộ.Stripe signs with the Stripe-Signature header, VNPay signs vnp_SecureHash with HMAC-SHA512 over the query string, MoMo signs a signature field. One adapter per provider verifies and maps to an internal PaymentEvent.
Ký payload bằng secret riêng của từng endpoint, kèm timestamp và event id. Thử lại với exponential backoff + jitter (1 phút, 5 phút, 30 phút… tới 24 giờ), quá 8 lần thì vào DLQ; endpoint lỗi liên tục 3 ngày thì tắt và email cho khách.Sign the payload with each endpoint's own secret and include a timestamp and event id. Retry with exponential backoff and jitter (1 min, 5 min, 30 min… up to 24 h), park it in the DLQ after 8 attempts, and disable an endpoint that has failed for 3 days and email the customer.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
processed_event | provider, event_id, event_type, received_at | PK(provider, event_id); xoá sau 30 ngày, dài hơn thời gian provider còn gửi lạiPK(provider, event_id); purged after 30 days, longer than any provider retry period |
payment | id, provider_ref, order_id, status, last_event_at, version | Chỉ chuyển PENDING → SUCCEEDED → REFUNDED; sự kiện cũ hơn last_event_at bị bỏ quaOnly PENDING → SUCCEEDED → REFUNDED; events older than last_event_at are ignored |
webhook_endpoint | id, customer_id, url, secret, events, status, failing_since | status ACTIVE · DISABLED; tự tắt khi failing_since quá 3 ngàystatus ACTIVE · DISABLED; disabled automatically when failing_since is over 3 days old |
webhook_delivery | id, endpoint_id, event_id, attempt, next_attempt_at, response_code, status | Lịch sử gửi cho khách xem và gửi lại; PENDING → DELIVERED · DEADDelivery log for customers to inspect and resend; PENDING → DELIVERED · DEAD |
Receiver kiểm timestamp và HMAC, đẩy raw body vào Kafka rồi trả 200. Processor chống trùng bằng một lệnh INSERT … ON CONFLICT trong cùng transaction với cập nhật thanh toán.The receiver checks the timestamp and the HMAC, queues the raw body to Kafka and answers 200. The processor deduplicates with one INSERT … ON CONFLICT in the same transaction as the payment update.
@RestController
@RequiredArgsConstructor
public class StripeWebhook {
private final KafkaTemplate<String, String> kafka;
private final JdbcTemplate jdbc;
private final PaymentService payments;
@Value("${stripe.webhook-secret}") private String secret;
@PostMapping("/webhooks/stripe")
public ResponseEntity<Void> receive(@RequestBody String body,
@RequestHeader("Stripe-Signature") String header) throws Exception {
Map<String, String> sig = Signatures.parse(header); // t=…,v1=…
long t = Long.parseLong(sig.get("t"));
if (Math.abs(Instant.now().getEpochSecond() - t) > 300)
return ResponseEntity.status(401).build(); // replay window
byte[] expected = Signatures.hmacSha256(secret, t + "." + body);
byte[] given = HexFormat.of().parseHex(sig.get("v1"));
if (!MessageDigest.isEqual(expected, given))
return ResponseEntity.status(401).build();
String key = Signatures.objectId(body); // pi_… keeps per-payment order
kafka.send("webhook.inbound", key, body).get(2, TimeUnit.SECONDS);
return ResponseEntity.ok().build(); // ~20 ms
}
@KafkaListener(topics = "webhook.inbound")
@Transactional
public void process(String body) {
StripeEvent e = StripeEvent.parse(body);
int fresh = jdbc.update("""
INSERT INTO processed_event(provider, event_id, event_type)
VALUES ('stripe', ?, ?) ON CONFLICT DO NOTHING""", e.id(), e.type());
if (fresh == 0) return; // provider retry
payments.apply(e.objectId(), e.type(), e.created()); // forward-only transition
}
}type Receiver struct {
secret []byte
writer *kafka.Writer
}
func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, req.Body, 1<<20))
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
t, v1, ok := parseSignature(req.Header.Get("Stripe-Signature"))
if !ok || abs(time.Now().Unix()-t) > 300 {
http.Error(w, "missing or stale signature", http.StatusUnauthorized)
return
}
mac := hmac.New(sha256.New, r.secret)
fmt.Fprintf(mac, "%d.%s", t, body)
if !hmac.Equal(mac.Sum(nil), v1) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
defer cancel()
msg := kafka.Message{Key: []byte(objectID(body)), Value: body}
if err := r.writer.WriteMessages(ctx, msg); err != nil {
http.Error(w, "try again", http.StatusServiceUnavailable) // provider retries
return
}
w.WriteHeader(http.StatusOK)
}
func (p *Processor) Handle(ctx context.Context, e Event) error {
return p.tx.Do(ctx, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `INSERT INTO processed_event(provider, event_id, event_type)
VALUES ('stripe', $1, $2) ON CONFLICT DO NOTHING`, e.ID, e.Type)
if err != nil || tag.RowsAffected() == 0 {
return err // nil: duplicate delivery, skip
}
return applyPayment(ctx, tx, e) // locks the row, checks e.Created
})
}| Dùng payload webhookUse the webhook payload | Gọi API lấy trạng thái mới nhấtFetch the latest state from the API | |
|---|---|---|
| Độ trễLatency | Không thêm lời gọi mạngNo extra network call | Thêm 100–300 ms mỗi sự kiện100–300 ms more per event |
| Thứ tựOrdering | Phải tự so last_event_at, versionYou compare last_event_at or a version yourself | Luôn nhận trạng thái hiện tại, thứ tự không còn quan trọngYou always get the current state, so order stops mattering |
| Giới hạnLimits | Không tốn quota APINo API quota used | Chịu rate limit của provider (Stripe 100 req/s)Subject to provider rate limits (Stripe 100 req/s) |
| Chọn khiPick it when | Sự kiện độc lập, lưu lượng lớnEvents are independent and volume is high | Tiền, hoàn tiền, tranh chấp: cần trạng thái chắc chắnMoney, refunds, disputes: the state must be certain |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Provider đặt timeout ngắn (Stripe khoảng 10 giây) và coi timeout là lỗi, nên sẽ gửi lại, tạo vòng tải tăng dần. Ghi bền vào Kafka hoặc bảng inbox rồi trả 2xx; phần còn lại chạy bất đồng bộ và tự thử lại được.Providers use short timeouts (about 10 seconds for Stripe) and treat a timeout as a failure, so they resend and load keeps growing. Store the event durably in Kafka or an inbox table, answer 2xx, and let the rest run asynchronously with its own retries.
Không áp dụng mù quáng. Trạng thái chỉ được đi tiến (REFUNDED không quay về SUCCEEDED) và so event.created với last_event_at. Với luồng tiền, gọi API của provider lấy trạng thái hiện tại rồi ghi theo đó.Never apply events blindly. Only forward transitions are allowed (REFUNDED never returns to SUCCEEDED), and event.created is compared with last_event_at. For money flows, call the provider API for the current state and store that.
Có. Chữ ký chỉ chứng minh nguồn gốc. Provider gửi lại cùng sự kiện với chữ ký hợp lệ, và replay trong cửa sổ 5 phút cũng hợp lệ. Dedup theo event id mới đảm bảo áp dụng một lần.Yes. The signature only proves origin. Providers resend the same event with a valid signature, and a replay inside the 5-minute window is valid too. Dedup by event id is what guarantees a single application.
Hàng đợi và worker theo endpoint để nó không chặn khách khác; backoff tăng dần tới 24 giờ, quá 8 lần thì vào DLQ; lỗi liên tục 3 ngày thì DISABLED và email cho khách. Khi khách sửa xong, họ bật lại và gửi lại từ lịch sử.Queue and rate-limit per endpoint so it never blocks other customers; back off up to 24 hours, park it in the DLQ after 8 attempts, and mark it DISABLED and email the customer after 3 days of failures. Once fixed, the customer re-enables it and replays from the delivery log.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.