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); } try { /* * Paddle can deliver several related webhook events at almost * exactly the same time. * * Laravel will automatically retry the entire transaction when * MySQL reports a deadlock / serialization failure. * * Five attempts gives short-lived webhook contention time to * resolve without weakening any database constraints. */ DB::transaction( function () use ( $provider, $externalID, $eventType, $payload, $raw, $provisioner ): void { /* * firstOrCreate protects against Paddle retrying the exact * same event ID. * * lockForUpdate prevents two workers handling the same * webhook event concurrently after the row exists. */ LicenseWebhookEvent::firstOrCreate( [ 'provider' => $provider, 'external_event_id' => $externalID, ], [ 'event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload, ] ); $event = LicenseWebhookEvent::query() ->where('provider', $provider) ->where('external_event_id', $externalID) ->lockForUpdate() ->firstOrFail(); /* * Already successfully processed. * * Returning here makes duplicate delivery of the exact * same Paddle event idempotent. */ if ($event->processed_at !== null) { return; } /* * An external event ID must never be reused with different * content. */ $incomingHash = hash('sha256', $raw); if (! hash_equals( (string) $event->payload_sha256, $incomingHash )) { throw new \RuntimeException( 'Webhook ID was reused with a different payload.' ); } try { $provisioner->provision( $provider, $payload ); $event->update([ 'processed_at' => now(), 'processing_error' => null, ]); } catch (\Throwable $e) { /* * This update is part of the same transaction. * * If processing fails and the transaction rolls back, * Paddle can retry the webhook normally. */ $event->update([ 'processing_error' => mb_substr( $e->getMessage(), 0, 4000 ), ]); throw $e; } }, 5 ); } catch (\Throwable $e) { report($e); return response()->json([ 'message' => 'Webhook could not be processed.', 'exception' => get_class($e), 'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), ], 500); } /* * Delivery is intentionally outside the webhook transaction. * * A temporary mail outage must not roll back an otherwise valid * payment event. Pending outbox rows remain available for portal * resend or a later webhook delivery attempt. */ try { $delivery->deliverPending(10); } catch (\Throwable $e) { report($e); } return response()->json([ 'ok' => true, ]); } }