Chi Tiết Xử Lý Stripe Webhook
Mô Tả
Tài liệu này cung cấp chi tiết toàn diện về cách hệ thống xử lý webhook từ Stripe, đảm bảo tính idempotency (không trùng lặp), tính nhất quán dữ liệu và xử lý lỗi phù hợp. Nó bao gồm kiến trúc, xác minh webhook, định tuyến sự kiện và các best practices được triển khai trong codebase.
Hệ thống webhook là xương sống của quản lý subscription, vì tất cả các thay đổi trạng thái quan trọng đều bắt nguồn từ Stripe và được truyền đạt thông qua các sự kiện này. Xử lý đúng cách là điều cần thiết để duy trì đồng bộ giữa Stripe và database local.
Kiến Trúc Webhook
---
config:
theme: base
layout: dagre
flowchart:
curve: linear
htmlLabels: true
themeVariables:
edgeLabelBackground: "transparent"
---
flowchart TD
%% == CÁC NÚT ==
Stripe((Stripe API))
subgraph WebhookLayer["Lớp Webhook Controller"]
WebhookController[WebhookController]
Verification{Xác Minh<br/>Chữ Ký}
Idempotency{Kiểm Tra<br/>Idempotency}
Router{Định Tuyến<br/>Sự Kiện}
end
subgraph HandlerLayer["Lớp Event Handler"]
SessionHandler[SubscriptionSessionHandler]
LifecycleHandler[SubscriptionLifeCycleHandler]
InvoiceHandler[SubscriptionInvoiceHandler]
PaymentIntentHandler[PaymentIntentHandler]
end
subgraph Database["Lớp Database"]
WebhookEventsDB[(stripe_webhook_events)]
SubscriptionDB[(subscriptions)]
HistoryDB[(subscription_histories)]
end
%% == KẾT NỐI ==
Stripe -->|1. Gửi Event| WebhookController
WebhookController --> Verification
Verification -->|Hợp lệ| Idempotency
Verification -->|Không hợp lệ| Error403[403 Forbidden]
Idempotency -->|Kiểm tra| WebhookEventsDB
WebhookEventsDB -->|Trạng thái| Idempotency
Idempotency -->|Mới/Thất bại| Router
Idempotency -->|Hoàn thành/Đang xử lý| Return200[200 OK]
Router -->|checkout.session.completed| SessionHandler
Router -->|customer.subscription.*| LifecycleHandler
Router -->|invoice.*| InvoiceHandler
Router -->|payment_intent.succeeded| PaymentIntentHandler
SessionHandler --> SubscriptionDB
SessionHandler --> HistoryDB
LifecycleHandler --> SubscriptionDB
LifecycleHandler --> HistoryDB
InvoiceHandler --> SubscriptionDB
InvoiceHandler --> HistoryDB
PaymentIntentHandler --> HistoryDB
SessionHandler --> UpdateWebhook[Cập Nhật Trạng Thái Event]
LifecycleHandler --> UpdateWebhook
InvoiceHandler --> UpdateWebhook
PaymentIntentHandler --> UpdateWebhook
UpdateWebhook --> WebhookEventsDB
UpdateWebhook --> FinalResponse[200 OK]
%% == STYLING ==
style Stripe fill:#fcd9d9,stroke:#cc3333,stroke-width:2px
style WebhookLayer fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
style HandlerLayer fill:#f0f8e6,stroke:#339933,stroke-width:2px
style Database fill:#ffe6cc,stroke:#ff9900,stroke-width:2px
style Error403 fill:#ffcccc,stroke:#cc0000,stroke-width:2px
style Return200 fill:#ccffcc,stroke:#00cc00,stroke-width:2px
style FinalResponse fill:#ccffcc,stroke:#00cc00,stroke-width:2px
Luồng Xử Lý Webhook Event
1. Xác Minh Chữ Ký
Mục đích: Đảm bảo request webhook thực sự đến từ Stripe và không bị giả mạo.
Triển khai:
// WebhookController.php
private function getLiveEventObject(Request $request): object
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$webhookSecret = config('stripe.config.webhook_secret');
if (empty($sigHeader) || empty($webhookSecret)) {
throw new \UnexpectedValueException('Thiếu chữ ký hoặc webhook secret');
}
// Stripe SDK xác minh chữ ký HMAC
return Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
}
Xử lý lỗi:
- Thiếu chữ ký →
403 Forbidden - Chữ ký không hợp lệ →
403 Forbidden(ngăn chặn replay attacks) - Payload không đúng định dạng →
400 Bad Request
2. Kiểm Tra Idempotency
Mục đích: Ngăn chặn xử lý cùng một event nhiều lần (Stripe có thể retry webhook).
Triển khai:
// WebhookController.php
private function getOrCreateWebhookEvent(object $event, ?string $requestId): object
{
$webhookEvent = $this->stripeWebhookEventRepository->findExistingEvent(
$event->data->object['id'],
$requestId,
$event->type
);
$eventStatus = ($webhookEvent && $webhookEvent->status === StripeWebhookEventStatus::Failed->value)
? StripeWebhookEventStatus::Failed->value
: StripeWebhookEventStatus::Pending->value;
return $this->stripeWebhookEventRepository->updateOrCreate(
[
'stripe_event_id' => $event->data->object['id'],
'request_id' => $requestId,
'event_type' => $event->type
],
['status' => $eventStatus]
);
}
Database Schema:
CREATE TABLE stripe_webhook_events (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
stripe_event_id VARCHAR(255) NOT NULL,
request_id VARCHAR(255),
event_type VARCHAR(255) NOT NULL,
status ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending',
error TEXT,
processed_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Indexes for efficient filtering and lookup
INDEX idx_status (status),
INDEX idx_event_type (event_type),
INDEX idx_stripe_event_id (stripe_event_id),
INDEX idx_request_id (request_id),
-- Unique constraint for idempotency
UNIQUE KEY unique_event (stripe_event_id, request_id, event_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Chuyển đổi trạng thái:
pending→ Event đã tạo, chưa xử lýprocessing→ Đang xử lýcompleted→ Xử lý thành côngfailed→ Xử lý thất bại, sẽ retry
3. Định Tuyến Event
Các loại event được xử lý:
| Loại Event | Handler | Mục đích |
|---|---|---|
checkout.session.completed |
SubscriptionSessionHandler | Kích hoạt subscription mới sau thanh toán |
checkout.session.async_payment_succeeded |
SubscriptionSessionHandler | Xử lý thanh toán async hoàn tất |
customer.subscription.created |
(Bỏ qua) | Đã xử lý qua checkout.session.completed |
customer.subscription.updated |
SubscriptionLifeCycleHandler | Thay đổi gói, hủy, gia hạn |
customer.subscription.deleted |
SubscriptionLifeCycleHandler | Hủy cuối cùng |
invoice.paid |
SubscriptionInvoiceHandler | Thanh toán thành công (gia hạn, đổi gói) |
invoice.payment_failed |
SubscriptionInvoiceHandler | Xử lý thanh toán thất bại |
payment_intent.succeeded |
PaymentIntentHandler | Cập nhật history với payment intent ID |
Logic định tuyến:
// WebhookController.php
private array $eventHandlers = [
WEBHOOK_EVENT_CHECKOUT_SESSION_COMPLETED => 'handleCheckoutSession',
WEBHOOK_EVENT_SUBSCRIPTION_UPDATED => 'handleSubscriptionUpdated',
WEBHOOK_EVENT_SUBSCRIPTION_DELETED => 'handleSubscriptionDeleted',
WEBHOOK_EVENT_INVOICE_PAID => 'handleInvoicePaid',
WEBHOOK_EVENT_INVOICE_PAYMENT_FAILED => 'handleInvoicePaymentFailed',
WEBHOOK_EVENT_PAYMENT_INTENT_SUCCEEDED => 'handlePaymentIntentSucceeded'
];
4. Thực Thi Handler
An toàn Transaction: Tất cả handler đều wrap các thao tác database trong transaction:
// Ví dụ từ SubscriptionLifeCycleHandler
private function wrapTransaction($callback)
{
return $this->subscriptionRepository->transaction($callback);
}
public function handleSubscriptionUpdated($session, $previousAttributes)
{
return $this->wrapTransaction(function () use ($session, $previousAttributes) {
// Tất cả thao tác DB ở đây là atomic
$this->updateSubscription(...);
$this->createHistory(...);
});
}
Ví Dụ Thực Tế Từ Log (stripe-2026-01-06.log)
Các ví dụ dưới đây được trích từ stripe-2026-01-06.log để minh họa những case phổ biến. Log chỉ hiển thị các trường trọng yếu (ID, amount, invoice trạng thái). Interval thực tế (daily/monthly/yearly) được xác định từ price.recurring.interval trong payload Stripe.
1) Upgrade (Free -> Basic) — Đổi gói ngay, có invoice trả tiền
Luồng chính:
customer.subscription.updated->SubscriptionLifeCycleHandlerinvoice.paid+payment_intent.succeeded->SubscriptionInvoiceHandler
Log tiêu biểu:
[2026-01-06 16:25:25] local.INFO: Immediate plan change detected {"subscription_id":234,"new_plan_id":"price_1QZO2IC6W0lx7trg9iz1f9Rn"}
[2026-01-06 16:25:26] local.INFO: Immediate plan change finalized from Stripe latest_invoice (payment already settled) {"subscription_id":234,"change_history_id":3417,"invoice_id":"in_1SmUdmC6W0lx7trg6wkk44yS","amount_due":2000,"invoice_status":"paid","payment_intent_status":"succeeded"}
Kết quả:
subscription_histories: type =change, payment_status =paid, cóinvoice_id,payment_intent_id.
2) Downgrade (Paid -> Free) — Đổi gói ngay, không phát sinh invoice
Luồng chính:
customer.subscription.updated->SubscriptionLifeCycleHandler
Log tiêu biểu:
[2026-01-06 15:23:02] local.INFO: Immediate plan change detected {"subscription_id":231,"new_plan_id":"price_1RnD3yC6W0lx7trgicZwdJbN"}
[2026-01-06 15:23:02] local.INFO: Immediate plan change finalized with no invoice {"subscription_id":231,"change_history_id":3407,"new_plan_amount":0,"payment_status":"n/a"}
Kết quả:
subscription_histories: type =change, payment_status =n/a, không cóinvoice_id.
3) Change plan theo chu kỳ (daily/monthly/yearly)
Trong log có nhiều lần đổi plan ngay (immediate) với các price khác nhau; interval được đọc từ Stripe price:
[2026-01-06 16:27:36] local.INFO: Immediate plan change detected {"subscription_id":234,"new_plan_id":"price_1QZO4IC6W0lx7trg01Mh3Z5a"}
[2026-01-06 16:27:38] local.INFO: Immediate plan change finalized from Stripe latest_invoice (payment already settled) {"subscription_id":234,"change_history_id":3419,"invoice_id":"in_1SmUfvC6W0lx7trgvuZ48LUf","amount_due":999,"invoice_status":"paid","payment_intent_status":"succeeded"}
[2026-01-06 16:39:47] local.INFO: Immediate plan change detected {"subscription_id":234,"new_plan_id":"price_1QMoGlC6W0lx7trgnOM4q2YW"}
[2026-01-06 16:39:48] local.INFO: Immediate plan change finalized from Stripe latest_invoice (payment already settled) {"subscription_id":234,"change_history_id":3420,"invoice_id":"in_1SmUrgC6W0lx7trgAIdMSUN7","amount_due":56789,"invoice_status":"paid","payment_intent_status":"succeeded"}
Kết quả:
subscription_histories: type =change, payment_status =paid, interval lấy từprice.recurring.interval.
4) Cancel theo lịch (scheduled cancellation)
Luồng chính:
customer.subscription.updated->SubscriptionLifeCycleHandler
Log tiêu biểu:
[2026-01-06 16:43:57] local.INFO: Subscription updated for scheduled cancellation {"subscription_id":234,"deadline_at":"2026-02-06 16:39:40","grace_period_end_at":"2026-02-13 16:39:40"}
[2026-01-06 16:43:57] local.INFO: Subscription marked for scheduled cancellation, and pending histories invalidated {"subscription_id":234,"cancellation_requested_at":"2026-01-06 16:43:55","cancellation_effective_at":"2026-02-06 16:39:40","invalidated_histories":[3421]}
Kết quả:
subscriptions.statuschuyển sangpending_cancellation.- Các
subscription_historiespending bị vô hiệu hóa để tránh tính quota sai.
5) Resume sau khi cancel theo lịch
Luồng chính:
customer.subscription.updated->SubscriptionLifeCycleHandler(resume)
Log tiêu biểu:
[2026-01-06 16:50:26] local.INFO: Subscription status transition {"subscription_id":234,"from":"pending_cancellation","to":"active","context":{"event":"subscription.updated","stripe_subscription_id":"sub_1SmUd3C6W0lx7trg06YbgX1Y","action":"resume"}}
[2026-01-06 16:50:26] local.INFO: Subscription resumed from scheduled cancellation {"subscription_id":234}
Kết quả:
subscriptions.statusquay lạiactive.- Lịch hủy bị xóa và history pending hủy được dọn.
Xử lý lỗi:
// WebhookController.php
private function processStripeEvent(object $event, object $webhookEvent): void
{
try {
$this->logStripe("Đang xử lý Stripe event: {$event->type}");
$handler = $this->eventHandlers[$event->type];
$item = (object)$event->data->object;
$previousAttributes = isset($event->data->previous_attributes)
? (object)$event->data->previous_attributes
: null;
$this->{$handler}($item, $previousAttributes);
$webhookEvent->update(['status' => StripeWebhookEventStatus::Completed->value]);
} catch (\Throwable $th) {
$webhookEvent->update([
'status' => StripeWebhookEventStatus::Failed->value,
'error' => $th->getMessage()
]);
$this->logStripe("Lỗi xử lý event {$event->type}: " . $th->getMessage());
throw $th; // Sẽ trả về 500, trigger Stripe retry
}
}
Các Biện Pháp Đảm Bảo Tính Toàn Vẹn Dữ Liệu
1. Toàn vẹn Foreign Key
Vấn đề: packageToProvider->package_id có thể tham chiếu đến package không tồn tại.
Giải pháp: Luôn sử dụng chuỗi relationship:
// SAI
'package_id' => $packageToProvider->package_id,
// ĐÚNG
$packagePlan = $packagePlanToProvider->packagePlan;
'package_id' => $packagePlan->package_id,
2. Ngăn Chặn History Trùng Lặp
Cơ chế kiểm tra:
// Kiểm tra history hiện có trong khoảng thời gian cho phép (±5 giây)
$periodStart = format_timestamp($session->current_period_start);
$periodStartCarbon = \Carbon\Carbon::parse($periodStart);
$existingHistory = $this->subscriptionHistoryRepository
->findWhere([
'subscription_id' => $subscription->id,
'type' => $type->value,
])
->filter(function($history) use ($periodStartCarbon) {
$historyStart = \Carbon\Carbon::parse($history->started_at);
return abs($historyStart->diffInSeconds($periodStartCarbon)) <= 5;
})
->first();
if ($existingHistory) {
return $existingHistory; // Bỏ qua tạo mới
}
Tại sao dung sai 5 giây?
- Stripe có thể có sự khác biệt nhỏ về timestamp giữa các webhook event
period_startcủa Invoice vscurrent_period_startcủa Subscription có thể chênh vài giây
3. An Toàn Null
Tất cả truy cập array/object đều sử dụng accessor an toàn:
data_get()cho nested array- Null coalescing (
??) cho object property - Kiểm tra null tường minh trước thao tác
Xử Lý Lỗi và Ghi Log
Các Mức Log
Info: Thao tác bình thường
$this->logStripe('Subscription cập nhật thành công', [
'subscription_id' => $subscription->id
]);
Warning: Tình huống không mong đợi nhưng có thể phục hồi
$this->logStripeWarning('Invoice không có billing_reason', [
'invoice_id' => $session->id
]);
Error: Lỗi nghiêm trọng
$this->logStripeError('Tạo change history thất bại', [
'error' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
Thông Báo Slack
Lỗi nghiêm trọng cũng gửi alert Slack:
$this->sendSlack('Nghiêm trọng: Kích hoạt subscription thất bại', $context, NOTIFICATION_ERROR);
Testing và Debug
Test Webhook Local
Sử dụng Stripe CLI:
stripe listen --forward-to http://localhost/api/v1/admin/stripe/webhook
Test thủ công với Postman:
- Sử dụng header:
X-Test-Event: customer.subscription.updated - Xác minh chữ ký được bypass trong môi trường local
Log Webhook
Kiểm tra lịch sử xử lý trong bảng stripe_webhook_events:
SELECT * FROM stripe_webhook_events
WHERE event_type = 'customer.subscription.updated'
ORDER BY created_at DESC
LIMIT 10;
Log Ứng Dụng
Tất cả xử lý webhook được ghi vào storage/logs/stripe-{date}.log:
[2025-12-17 17:20:43] local.INFO: Đang xử lý Stripe event: customer.subscription.updated
[2025-12-17 17:20:43] local.INFO: Subscription cập nhật thành công {"subscription_id":196}
Best Practices
- Luôn xác minh chữ ký webhook trong production
- Triển khai idempotency sử dụng kiểm tra database-level
- Sử dụng transaction cho cập nhật nhiều bảng
- Ghi log toàn diện với context
- Xử lý null/dữ liệu thiếu một cách graceful
- Trả về 200 OK nhanh chóng để tránh Stripe retry
- Xử lý event bất đồng bộ nếu thao tác chậm (dùng queue)
- Giám sát event thất bại và triển khai logic retry
- Test kỹ race condition
- Giữ handler tập trung vào single responsibility
Trạng thái tài liệu: Hoàn thành - Hệ thống xử lý webhook production-ready