├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Sources └── GumroadLicenseValidator │ ├── Extensions │ └── URLSession.swift │ ├── Model │ └── APIResponse.swift │ └── GumroadClient.swift ├── Package.swift ├── Tests └── GumroadLicenseValidatorTests │ └── GumroadClientTests.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Daniel Kasaj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/GumroadLicenseValidator/Extensions/URLSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSession.swift 3 | // GumroadLicenseCheck 4 | // 5 | // Created by Daniel Kasaj on 07.01.2022.. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URLSession { 11 | 12 | /// Fetches and decodes JSON data based on result type. 13 | /// Courtesy of Paul Hudson at the HackingWithSwift Live conference, Aug 2021 14 | func decode( 15 | _ type: T.Type = T.self, 16 | for urlRequest: URLRequest, 17 | keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, 18 | dataDecodingStrategy: JSONDecoder.DataDecodingStrategy = .deferredToData, 19 | dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .deferredToDate 20 | ) async throws -> T { 21 | let (data, _) = try await data(for: urlRequest) 22 | let decoder = JSONDecoder() 23 | decoder.keyDecodingStrategy = keyDecodingStrategy 24 | decoder.dataDecodingStrategy = dataDecodingStrategy 25 | decoder.dateDecodingStrategy = dateDecodingStrategy 26 | let decoded = try decoder.decode(T.self, from: data) 27 | return decoded 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "GumroadLicenseValidator", 8 | platforms: [ 9 | .macOS(.v12) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "GumroadLicenseValidator", 15 | targets: ["GumroadLicenseValidator"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "GumroadLicenseValidator", 26 | dependencies: []), 27 | .testTarget( 28 | name: "GumroadLicenseValidatorTests", 29 | dependencies: ["GumroadLicenseValidator"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Tests/GumroadLicenseValidatorTests/GumroadClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GumroadLicenseCheckTests.swift 3 | // GumroadLicenseCheckTests 4 | // 5 | // Created by Daniel Kasaj on 07.01.2022.. 6 | // 7 | 8 | import XCTest 9 | @testable import GumroadLicenseValidator 10 | 11 | class GumroadLicenseCheckTests: XCTestCase { 12 | 13 | let testProductPermalink = "------" // Enter yours! 14 | let testLicenseKey = "XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX" // Enter yours! 15 | 16 | var sut: GumroadClient? 17 | 18 | override func setUpWithError() throws { 19 | // Put setup code here. This method is called before the invocation of each test method in the class. 20 | try super.setUpWithError() 21 | sut = GumroadClient(productPermalink: testProductPermalink) 22 | } 23 | 24 | override func tearDownWithError() throws { 25 | sut = nil 26 | } 27 | 28 | func test_canInit() { 29 | XCTAssertNotNil(GumroadClient(productPermalink: testProductPermalink)) 30 | 31 | XCTAssertNil(GumroadClient(productPermalink: "")) 32 | } 33 | 34 | func test_canMakeAPIURLRequest() async { 35 | let request = sut?.makeRequest(licenseKey: testLicenseKey) 36 | XCTAssertNotNil(request) 37 | 38 | let requestWithoutLicenseKey = sut?.makeRequest(licenseKey: "") 39 | XCTAssertNil(requestWithoutLicenseKey) 40 | } 41 | 42 | func test_canVerifyLicense() async throws { 43 | let result = await sut?.isLicenseKeyValid(testLicenseKey) 44 | XCTAssertTrue(try XCTUnwrap(result)) 45 | 46 | let invalidResult = await sut?.isLicenseKeyValid("not-a-valid-key") 47 | XCTAssertFalse(try XCTUnwrap(invalidResult)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gumroad License Validator 2 | 3 | ## Overview 4 | A super simple tool for macOS Swift developers to check validity of a Gumroad-issued software license keys 5 | 6 | ## Requirements 7 | Requires macOS 12 because it uses async/await APIs. 8 | 9 | If you really need it to have a completionHandler-based synchronous version, let me know 10 | 11 | ## Installation 12 | Swift-Gumroad-license-validator is available through [Swift Package Manager](https://swift.org/package-manager/). 13 | 1. In Xcode, click File > Add Packages... 14 | 2. Select GitHub under Source Control Accounts 15 | 3. Search for Swift-Gumroad-license-validator 16 | 4. Click "Add Package" in bottom right 17 | 18 | ## Usage 19 | ```swift 20 | import GumroadLicenseValidator 21 | ``` 22 | 23 | ```swift 24 | let licenseKeyToCheck = "XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX" 25 | 26 | let client = GumroadClient(productPermalink: "your product permalink") 27 | 28 | await client?.isLicenseKeyValid(licenseKeyToCheck) 29 | ``` 30 | ## How to get a product permalink from Gumroad 31 | Go to your product page on Gumroad. 32 | 33 | If your product URL is "https://gumroad.com/l/QMGY" your product_permalink would be "QMGY." 34 | 35 | ## How to get a test license key from Gumroad 36 | 1. Log in to your Gumroad account, open your product 37 | 2. Click the URL underneath your product's name to open the page 38 | 3. Click the purchase button ("I want this”, ”Buy”, or whatever you set it up to be) 39 | 4. You will see a checkout window with test card information already entered 40 | 5. Confirm the purchase, and then click “View content” to get the code (you'll also get it in an email) 41 | 42 | ## Good luck with your project 43 | If you're using this package it means you've decided to sell your app on Gumroad. May you have an exciting launch and many sales! Good luck! I'd love it if you let me know about your app, [@DanielKasaj on Twitter](https://twitter.com/DanielKasaj). 44 | 45 | ## License 46 | Swift-Gumroad-license-validator is available under the MIT license. See the [LICENSE](LICENSE) file for more information. 47 | 48 | -------------------------------------------------------------------------------- /Sources/GumroadLicenseValidator/Model/APIResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIResponse.swift 3 | // GumroadLicenseCheck 4 | // 5 | // Created by Daniel Kasaj on 07.01.2022.. 6 | // 7 | // swiftlint:disable identifier_name 8 | // swiftlint:disable nesting 9 | 10 | import Foundation 11 | 12 | extension GumroadClient { 13 | 14 | final class APIResponse: Decodable { 15 | let success: Bool? 16 | let uses: Int? 17 | let purchase: Purchase 18 | 19 | class Purchase: Decodable { 20 | let sellerID: String? 21 | let productID: String? 22 | let productName: String? 23 | let permalink: String? 24 | let productPermalink: String? 25 | let email: String? 26 | let price: Int? 27 | let gumroadFee: Int? 28 | let currency: String? 29 | let quantity: Int? 30 | let discoverFeeCharged: Bool? 31 | let canContact: Bool? 32 | let referrer: String? 33 | let orderNumber: Int? 34 | let saleID: String? 35 | let saleTimestamp: Date? 36 | let purchaserID: String? 37 | let subscriptionID: String? 38 | let variants: String? 39 | let licenseKey: String? 40 | let ipCountry: String? 41 | let recurrence: String? 42 | let isGiftReceiverPurchase: Bool? 43 | let refunded: Bool? 44 | let disputed: Bool? 45 | let disputeWon: Bool? 46 | let id: String? 47 | let createdAt: Date? 48 | let subscriptionEndedAt: Date? 49 | let subscriptionCancelledAt: Date? 50 | let subscriptionFailedAt: Date? // Date of inability to charge card 51 | 52 | enum CodingKeys: String, CodingKey { 53 | case sellerID = "seller_id" 54 | case productID = "product_id" 55 | case productName = "product_name" 56 | case permalink 57 | case productPermalink = "product_permalink" 58 | case email 59 | case price 60 | case gumroadFee = "gumroad_fee" 61 | case currency 62 | case quantity 63 | case discoverFeeCharged = "discover_fee_charged" 64 | case canContact 65 | case referrer 66 | case orderNumber = "order_number" 67 | case saleID = "sale_id" 68 | case saleTimestamp 69 | case purchaserID = "purchaser_id" 70 | case subscriptionID = "subscription_id" 71 | case variants 72 | case licenseKey = "license_key" 73 | case ipCountry = "ip_country" 74 | case recurrence 75 | case isGiftReceiverPurchase = "is_gift_receiver_purchase" 76 | case refunded 77 | case disputed 78 | case disputeWon = "dispute_won" 79 | case id 80 | case createdAt = "created_at" 81 | case subscriptionEndedAt = "subscription_ended_at" 82 | case subscriptionCancelledAt = "subscription_cancelled_at" 83 | case subscriptionFailedAt = "subscription_failed_at" 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/GumroadLicenseValidator/GumroadClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GumroadClient.swift 3 | // GumroadLicenseCheck 4 | // 5 | // Created by Daniel Kasaj on 07.01.2022.. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Simple class to send requests to Gumroad's Verify License API endpoint, which does not require an OAuth application. 11 | /// 12 | /// Class initializer is failable to ensure that the class has a product permalink. 13 | /// 14 | /// - note: Has a configurable property `disputedPurchaseInvalidatesLicense` 15 | public final class GumroadClient { 16 | let productPermalink: String 17 | 18 | /// Initializes only if product permalink string is not empty 19 | /// 20 | /// If your product URL is "https://gumroad.com/l/QMGY" your product permalink would be "QMGY." 21 | public init?(productPermalink: String) { 22 | guard productPermalink.isEmpty == false else { return nil } 23 | self.productPermalink = productPermalink 24 | } 25 | 26 | /// Checks validity of Gumroad-issued license key 27 | /// - Parameters: 28 | /// - licenseKey: Non-empty string, preferably sanitized (remember Little Bobby Tables!) 29 | /// - incrementUsesCount: Whether Gumroad should increment count of times a license has been checked 30 | /// - Returns: `true` only if a number of checks passed (see implementation) 31 | public func isLicenseKeyValid(_ licenseKey: String, incrementUsesCount: Bool = true) async -> Bool { 32 | guard let request = makeRequest(licenseKey: licenseKey, 33 | incrementUsesCount: incrementUsesCount) else { return false } 34 | let response: APIResponse? = try? await URLSession.shared.decode(for: request, dateDecodingStrategy: .iso8601) 35 | guard let success = response?.success, success, 36 | let purchase = response?.purchase else { return false } 37 | 38 | // Check that license key matches and purchase has not been refunded 39 | guard let verifiedKey = purchase.licenseKey, verifiedKey == licenseKey, 40 | let refunded = purchase.refunded, refunded == false 41 | else { return false } 42 | 43 | return true 44 | } 45 | 46 | /// Builds a URLRequest towards Gumroad API, which uses POST 47 | /// - Parameters: 48 | /// - licenseKey: Non-empty string, preferably sanitized (remember Little Bobby Tables!) 49 | /// - incrementUsesCount: Whether Gumroad should increment count of times a license has been checked 50 | /// - Returns: `URLRequest` with needed POST parameters 51 | func makeRequest(licenseKey: String, incrementUsesCount: Bool = true) -> URLRequest? { 52 | guard productPermalink.isEmpty == false, licenseKey.isEmpty == false else { return nil } 53 | guard let baseURL = URL(string: "https://api.gumroad.com/v2/licenses/verify"), 54 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) 55 | else { return nil } 56 | 57 | // Technique to avoid manually percent-encoding 58 | // https://stackoverflow.com/a/58356848 59 | components.queryItems = [ 60 | URLQueryItem(name: "product_permalink", value: productPermalink), 61 | URLQueryItem(name: "license_key", value: licenseKey) 62 | ] 63 | if incrementUsesCount == false { 64 | components.queryItems?.append(URLQueryItem(name: "increment_uses_count", value: "false")) 65 | } 66 | guard let query = components.url?.query else { return nil } 67 | 68 | // Finally, build the request 69 | var urlRequest = URLRequest(url: baseURL) 70 | urlRequest.httpMethod = "POST" 71 | urlRequest.httpBody = Data(query.utf8) 72 | return urlRequest 73 | } 74 | 75 | } 76 | --------------------------------------------------------------------------------