├── .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 |
--------------------------------------------------------------------------------