getContent(); $verifier = match ($provider) { 'paddle' => app(PaddleWebhookVerifier::class), 'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class), default => null, }; if (! $verifier) { return response()->json([ 'message' => 'Unknown webhook provider.', ], 404); } try { $verifier->verify($request, $raw); } catch (Throwable $e) { report($e); return response()->json([ 'message' => 'Invalid webhook signature.', ], 400); } try { $payload = json_decode( $raw, true, flags: JSON_THROW_ON_ERROR ); } catch (Throwable) { return response()->json([ 'message' => 'Invalid JSON.', ], 400); } $externalID = (string) ( $provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '') .':' .($payload['data']['id'] ?? '') ); $eventType = (string) ( $provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown') ); if ($externalID === '') { return response()->json([ 'message' => 'Webhook event has no stable identifier.', ], 400); } $payloadHash = hash('sha256', $raw); try { /* * Paddle can deliver several related events concurrently. * * Laravel retries this complete transaction when MySQL reports * a deadlock. Five attempts gives short-lived lock contention * enough opportunity to resolve without acknowledging an event * that was not actually processed. */ DB::transaction( function () use ( $provider, $externalID, $eventType, $payload, $payloadHash, $provisioner ): void { /* * Insert the event if this is its first delivery. * * Once present, lock the row so duplicate deliveries of * the same event cannot provision concurrently. */ LicenseWebhookEvent::firstOrCreate( [ 'provider' => $provider, 'external_event_id' => $externalID, ], [ 'event_type' => $eventType, 'payload_sha256' => $payloadHash, 'payload' => $payload, ] ); $event = LicenseWebhookEvent::query() ->where('provider', $provider) ->where('external_event_id', $externalID) ->lockForUpdate() ->firstOrFail(); if ($event->processed_at) { return; } if (! hash_equals( (string) $event->payload_sha256, $payloadHash )) { throw new RuntimeException( 'Webhook ID was reused with a different payload.' ); } try { $provisioner->provision( $provider, $payload ); $event->processed_at = now(); $event->processing_error = null; $event->save(); } catch (Throwable $e) { /* * This assignment is useful when the surrounding * transaction eventually succeeds. If processing * throws, the transaction itself is rolled back and * Paddle will retry the event. */ $event->processing_error = mb_substr( $e->getMessage(), 0, 4000 ); $event->save(); throw $e; } }, 5 ); } catch (Throwable $e) { report($e); return response()->json([ 'message' => 'Webhook could not be processed.', ], 500); } /* * Delivery deliberately happens outside the webhook transaction. * * A temporary SMTP failure must not undo a successfully processed * payment event. The outbox retains anything that still needs * delivery. */ try { $delivery->deliverPending(10); } catch (Throwable $e) { report($e); } return response()->json([ 'ok' => true, ]); } }