header('Paddle-Signature')); if ($secret === '') { throw new RuntimeException('Paddle webhook secret is not configured.'); } if ($header === '') { throw new RuntimeException('Paddle-Signature header is missing.'); } $parts = []; foreach (explode(';', $header) as $part) { [$key, $value] = array_pad( explode('=', trim($part), 2), 2, null ); if ($key !== null && $value !== null) { $parts[$key][] = $value; } } $timestamp = isset($parts['ts'][0]) ? (int) $parts['ts'][0] : 0; $signatures = $parts['h1'] ?? []; if ($timestamp === 0) { throw new RuntimeException( 'Paddle webhook signature has no timestamp.' ); } if ($signatures === []) { throw new RuntimeException( 'Paddle webhook signature has no h1 signature.' ); } $tolerance = max( 1, (int) config('paddle.timestamp_tolerance', 300) ); $age = abs(time() - $timestamp); if ($age > $tolerance) { throw new RuntimeException( "Paddle webhook timestamp is outside the allowed tolerance ({$age}s)." ); } $signedPayload = $timestamp . ':' . $rawBody; $expected = hash_hmac( 'sha256', $signedPayload, $secret ); Log::debug('Paddle webhook signature diagnostics', [ 'timestamp' => $timestamp, 'age_seconds' => $age, 'body_length' => strlen($rawBody), 'body_sha256' => hash('sha256', $rawBody), // Safe diagnostics — do NOT log the secret itself. 'secret_prefix' => substr($secret, 0, 11), 'secret_length' => strlen($secret), 'received_signature_prefixes' => array_map( static fn (string $signature): string => substr($signature, 0, 12), $signatures ), 'expected_signature_prefix' => substr($expected, 0, 12), ]); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return; } } throw new RuntimeException( 'Paddle webhook signature is invalid.' ); } }