<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nico</title>
    <description>The latest articles on DEV Community by Nico (@nicodemanez).</description>
    <link>https://dev.to/nicodemanez</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3931195%2Fdab7bca1-ba2a-4552-8cc3-6d01c539278e.jpeg</url>
      <title>DEV Community: Nico</title>
      <link>https://dev.to/nicodemanez</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nicodemanez"/>
    <language>en</language>
    <item>
      <title>Why I Built My Own Licensing SDK Instead of Using Paddle</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:27:00 +0000</pubDate>
      <link>https://dev.to/nicodemanez/why-i-built-my-own-licensing-sdk-instead-of-using-paddle-4j98</link>
      <guid>https://dev.to/nicodemanez/why-i-built-my-own-licensing-sdk-instead-of-using-paddle-4j98</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/why-i-built-keylight" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A short founder note on why Keylight exists. Every product starts as somebody's unsolved problem; this is mine, and if you are shipping a paid app you have probably run into the same one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem I kept hitting
&lt;/h2&gt;

&lt;p&gt;I wanted to sell a desktop app directly. Not through the App Store — directly, to customers I could actually talk to. The payment side was easy: Stripe is excellent and the decision took an afternoon.&lt;/p&gt;

&lt;p&gt;Then I got to licensing, and everything slowed down.&lt;/p&gt;

&lt;p&gt;Stripe takes the money. It does not give you a license key. It does not sign anything your app can verify. It does not know what a device activation is. The moment a customer has paid, you are on your own: you need to mint a key, sign it so it cannot be forged, deliver it, let the app check it, track devices, and revoke it on a refund. None of that is payment processing, so none of it is in Stripe.&lt;/p&gt;

&lt;p&gt;So I looked at the platforms that &lt;em&gt;do&lt;/em&gt; bundle licensing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the merchant-of-record platforms did not fit
&lt;/h2&gt;

&lt;p&gt;Paddle, Gumroad, and Lemon Squeezy all advertise license keys. I looked hard at each, and the same three problems came up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fee.&lt;/strong&gt; As merchants of record they charge around 5%, against Stripe's ~2.9%. On every sale, forever. Reasonable if it solved my problem well — but it did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline validation.&lt;/strong&gt; This was the dealbreaker. Their licensing is built around an &lt;em&gt;online&lt;/em&gt; validation API: to check a key, the app calls the platform's server. My app is a desktop app, and desktop apps run on planes, behind firewalls, and offline. An online-only check leaves no good option. Fail closed — refuse to run without a server response — and a paying customer who is simply offline cannot use what they bought. Fail open — keep running when the server is unreachable — and the check is trivially bypassed: block the app's network access and it can never re-check the license or learn it was revoked. The app never actually &lt;em&gt;verifies&lt;/em&gt; anything itself; it only knows what the server last told it. I wanted keys the app could verify &lt;em&gt;locally&lt;/em&gt; and cryptographically — confirm a license is genuine with no server call, while revocation still lands on a periodic re-check. That is not what these platforms are built for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in.&lt;/strong&gt; Their license keys are tied to their platform. The customer relationship is tied to their platform. If I ever wanted to leave, I would be re-issuing every license and renegotiating my own customer data. I did not want to start a multi-year product on a foundation I could not walk away from.&lt;/p&gt;

&lt;p&gt;None of these platforms is bad. They are just built for selling digital goods broadly, not for licensing a desktop app that has to work offline and outlive any one vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then Paddle proved the point
&lt;/h2&gt;

&lt;p&gt;Since I made this call, Paddle went and did the exact thing I was afraid of. Paddle Classic — the version with built-in license keys and the Mac licensing framework — is being wound down, and Paddle Billing, the platform everyone is now pushed onto, doesn't generate license keys or handle Mac trials and licensing at all. Paddle-led fulfillment, license keys included, simply wasn't carried forward. (&lt;a href="https://developer.paddle.com/migrate/paddle-classic/features" rel="noopener noreferrer"&gt;Paddle's own migration docs&lt;/a&gt; spell it out, and there's a help page literally titled &lt;a href="https://www.paddle.com/help/manage/sdk/what-happens-when-the-v3-mac-sdk-is-no-longer-updated" rel="noopener noreferrer"&gt;"What happens when the v3 Mac SDK is no longer updated?"&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Read that again: every developer who built their licensing on Paddle now has to tear it out and migrate to something else — not because they did anything wrong, but because the platform deprecated the layer underneath them. That is the precise failure mode that made me want to own this part of my stack. When licensing lives in signed keys your app verifies itself, on your own Stripe account, a vendor sunsetting a product is an inconvenience — not a forced migration of your entire customer base.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually wanted
&lt;/h2&gt;

&lt;p&gt;Stripped down, my requirements were small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep my &lt;strong&gt;own Stripe account&lt;/strong&gt; — my customers, my data, my standard rate.&lt;/li&gt;
&lt;li&gt;License keys that are &lt;strong&gt;cryptographically signed&lt;/strong&gt;, so they cannot be forged or edited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline verification&lt;/strong&gt; — the app checks the key locally, no network call to launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device activation limits&lt;/strong&gt;, so one key is not a master key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic revocation&lt;/strong&gt; when Stripe processes a refund.&lt;/li&gt;
&lt;li&gt;An integration small enough to add in an afternoon, not a sprint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list is not exotic. It is just &lt;em&gt;the licensing layer&lt;/em&gt; — the piece that belongs between Stripe and the app, that nobody was selling as its own thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I built the layer
&lt;/h2&gt;

&lt;p&gt;Keylight is that layer. It connects to your own Stripe account. When a payment completes, it mints an Ed25519-signed license lease and delivers it. Your app ships with a public key and verifies the lease locally — offline, instantly. Refunds revoke the license automatically. Device limits are enforced per machine. The whole client side is a manager and a state switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;KeylightSDK&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;licensing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try!&lt;/span&gt; &lt;span class="kt"&gt;Keylight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;sdkKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"sdk_live_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"myapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;keyPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ACME"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;trustedPublicKeyBase64&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;your-public-key&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;trialDurationDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;branding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;appName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"My App"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;purchaseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://acme.example.com/buy"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;supportEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"support@acme.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tintColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;licensing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkOnLaunch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;licensing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;licensed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nf"&gt;enablePaidFeatures&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nf"&gt;showTrialBanner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;daysLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="nf"&gt;showRenewalPrompt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;invalid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="nf"&gt;showActivationSheet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The positioning is deliberate and narrow: &lt;strong&gt;use Stripe for payments, use Keylight for licensing.&lt;/strong&gt; Keylight is not a Stripe competitor and not a merchant of record. It is the missing layer — the one I went looking for, did not find, and decided to build. What began as a Swift SDK is cross-platform now — Rust and JavaScript/TypeScript too — but the model never changed: signed leases your app verifies offline, on your own Stripe.&lt;/p&gt;

&lt;p&gt;If you are weighing the same decision, the &lt;a href="https://keylight.dev/paddle-alternative" rel="noopener noreferrer"&gt;Paddle alternative&lt;/a&gt; page lays out the comparison in full. And if your licensing story took a different turn than mine, I would genuinely like to hear it — &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why build a licensing SDK instead of using Paddle?
&lt;/h3&gt;

&lt;p&gt;Merchant-of-record platforms bundle licensing into their fee, but their license handling is platform-tied and weak on offline validation. A dedicated layer on Stripe gives lower fees, customer ownership, and proper offline keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is wrong with licensing from Gumroad or Lemon Squeezy?
&lt;/h3&gt;

&lt;p&gt;Their license keys are tied to the platform and built around an online validation API. For a desktop app that must launch offline, that is the wrong model, and the keys are not portable if you leave.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does Keylight do differently?
&lt;/h3&gt;

&lt;p&gt;Keylight sits on top of your own Stripe account and issues Ed25519-signed license leases your app verifies offline, with device activation limits and automatic revocation on refund.&lt;/p&gt;

</description>
      <category>indiedev</category>
      <category>startup</category>
      <category>programming</category>
      <category>saas</category>
    </item>
    <item>
      <title>Migrate License Keys Without Breaking Existing Customers</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:26:56 +0000</pubDate>
      <link>https://dev.to/nicodemanez/migrate-license-keys-without-breaking-existing-customers-hkl</link>
      <guid>https://dev.to/nicodemanez/migrate-license-keys-without-breaking-existing-customers-hkl</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/migrate-license-keys-without-breaking-customers" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The thing that stops developers from moving their licensing isn't the work. It's the fear of one specific moment: a paying customer opens the app after you've switched, and it tells them they're unlicensed. That's the nightmare — you reach for lower fees and customer ownership, and the bill comes due as a wave of "I already paid for this" support tickets.&lt;/p&gt;

&lt;p&gt;It's a reasonable fear, and it's also avoidable. Migrating onto Keylight doesn't require invalidating anything, re-issuing anything, or asking customers to do anything. This post is about the one rule that keeps everyone working, the two situations you might be in, and why a scary-sounding "major version" jump changes none of it. When you're ready for the click-by-click mechanics, the companion piece covers them: &lt;a href="https://keylight.dev/blog/import-existing-customer-base" rel="noopener noreferrer"&gt;How to Import an Existing Customer Base into Keylight&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why migrating licensing feels risky
&lt;/h2&gt;

&lt;p&gt;A license check is binary in the moment a customer experiences it: the app either lets them in or it doesn't. So any change to the system &lt;em&gt;behind&lt;/em&gt; that check feels like it's playing with a live wire. Switch the layer that answers "is this person allowed in," the thinking goes, and you risk every existing customer getting the wrong answer at once.&lt;/p&gt;

&lt;p&gt;That instinct is right about the stakes and wrong about the mechanism. The wave of lockouts people picture comes from one specific mistake: treating migration as a &lt;em&gt;cutover&lt;/em&gt;, where the old keys stop being recognized the instant the new system goes live. If your migration invalidates the old keys, yes — everyone breaks. The entire trick is to not do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule: old keys stay valid
&lt;/h2&gt;

&lt;p&gt;Here's the rule the whole migration hangs on: &lt;strong&gt;you bring your customers' keys in as they are, and nothing gets invalidated.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you import an existing customer, their license is a live, &lt;code&gt;active&lt;/code&gt; record from the first second. If you include the key string they already have, &lt;em&gt;that&lt;/em&gt; key is what Keylight stores — not a replacement. So when your new build asks Keylight "is this key good," the answer is yes, because it's the same key, now on a system that recognizes it. The customer never repurchases and never loses access — though &lt;em&gt;how&lt;/em&gt; that key first reaches Keylight (your app reads it, or the customer enters it once) deserves its own section, below.&lt;/p&gt;

&lt;p&gt;This is also why running both systems in parallel for a little while is the safe play, not a hedge. Keep your old checkout and verification alive for a few weeks while customers drift onto the new build, then retire the old path once it's quiet. There's no single instant where everything has to flip. Migrations break when someone tries to flip everything at once; they're uneventful when the old keys keep working the whole way through.&lt;/p&gt;

&lt;p&gt;From your app's side, an imported key needs no special handling. It resolves to the same state a freshly minted key would:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Once a key has been activated once, an imported license validates exactly&lt;/span&gt;
&lt;span class="c1"&gt;// like one you minted by hand — there is no "migrated" branch in your launch&lt;/span&gt;
&lt;span class="c1"&gt;// logic. checkOnLaunch() reads the cached, locally-verified lease and maps it.&lt;/span&gt;
&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;licenseManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkOnLaunch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;licensed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;// The customer's existing key resolved to an active lease.&lt;/span&gt;
    &lt;span class="c1"&gt;// Show the full app. Imported or not, this looks identical.&lt;/span&gt;
    &lt;span class="nf"&gt;showMainWindow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;limited&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;// A lapsed subscription with fallback access — renew prompt, keep&lt;/span&gt;
    &lt;span class="c1"&gt;// export available. Not relevant to most one-time-license imports.&lt;/span&gt;
    &lt;span class="nf"&gt;showRenewBanner&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;invalid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;// No license stored, or verification failed. New users land here.&lt;/span&gt;
    &lt;span class="nf"&gt;showActivationSheet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no &lt;code&gt;case .migrated&lt;/code&gt;. Provenance lives in your dashboard, not in your app's control flow — the SDK has nothing to special-case, because an imported &lt;code&gt;active&lt;/code&gt; license &lt;em&gt;is&lt;/em&gt; an active license.&lt;/p&gt;

&lt;h2&gt;
  
  
  How your app picks up the existing key
&lt;/h2&gt;

&lt;p&gt;This is the honest crux, and the question that decides whether the migration is &lt;em&gt;invisible&lt;/em&gt; or merely &lt;em&gt;painless&lt;/em&gt;: a customer who bought before Keylight existed has no Keylight lease on their machine — no Keychain entry, no signed token, nothing to find. The lease that proves entitlement gets &lt;strong&gt;created the first time their key is activated against Keylight&lt;/strong&gt;. Import seeds the record on Keylight's servers; it never reaches onto the customer's device. So something has to hand the key to Keylight once. There are three ways, and they differ only in how much &lt;em&gt;you&lt;/em&gt; do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The customer enters their key once — you write no migration code.&lt;/strong&gt; Ship your new build with the activation UI you already integrated (&lt;code&gt;LicensePromptView&lt;/code&gt;). A returning customer who isn't activated yet sees the prompt, pastes the key they already own, and it validates immediately — because you imported it. One paste, no purchase. This is the floor: it always works and costs you nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Your app reads the old key and activates it silently — a few lines of your code.&lt;/strong&gt; If your &lt;em&gt;previous&lt;/em&gt; system stored the raw key somewhere your new build can read — UserDefaults, a plist, its own Keychain item — read it on first launch and call &lt;code&gt;manager.activate(key:)&lt;/code&gt; yourself. The customer sees nothing at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One-time migration shim. If this install has no Keylight license yet, read&lt;/span&gt;
&lt;span class="c1"&gt;// the key your OLD system stored and activate it. After this, the cached lease&lt;/span&gt;
&lt;span class="c1"&gt;// drives every launch.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;licensed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;licenseState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// already migrated — nothing to do&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;legacyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"licenseKey"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ^ YOUR previous storage — NOT a KeylightSDK call. Swap in your own read&lt;/span&gt;
    &lt;span class="c1"&gt;//   (plist, a file, your old Keychain item). Only your app knows where the&lt;/span&gt;
    &lt;span class="c1"&gt;//   legacy key lived; the SDK can't, so there's no helper for this part.&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;legacyKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// ← this IS the SDK call&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;UserDefaults&lt;/code&gt; read is a stand-in for &lt;em&gt;your&lt;/em&gt; old storage — it is not a KeylightSDK call, and Keylight can't ship one, because only you know where the key lived or whether it's recoverable at all. The only SDK call here is &lt;code&gt;manager.activate(key:)&lt;/code&gt;. Two things have to be true for this path: the old key is readable on the device, and you imported with that same key string (the &lt;code&gt;license_key&lt;/code&gt; column) so the server record matches. When they are, the migration is genuinely invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Keylight's hosted claim page — no app code at all.&lt;/strong&gt; Turn on &lt;a href="https://docs.keylight.dev/concepts/customer-portal/#claiming-a-legacy-key" rel="noopener noreferrer"&gt;Claim a legacy key&lt;/a&gt; and customers prove ownership of their old key at a Keylight-hosted portal page; Keylight mints a license and emails it, white-labeled. You write nothing in the app. The tradeoff: it mints a &lt;em&gt;fresh&lt;/em&gt; key rather than preserving the old string, and it's customer-driven — a good fit for a frozen app whose key you can't read anyway.&lt;/p&gt;

&lt;p&gt;The takeaway for the nervous developer: &lt;strong&gt;none of this requires you to write code for the migration to work.&lt;/strong&gt; Routes 1 and 3 are zero app code. You only reach for the shim in Route 2 when you want the customer to notice &lt;em&gt;nothing whatsoever&lt;/em&gt; — and even then it's a few lines against a key you've already preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two paths — pick yours
&lt;/h2&gt;

&lt;p&gt;There are two situations a developer is usually in when they migrate, and they call for different moves. The good news is that both are safe; they're just safe for different reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your old app can't ship an update.&lt;/strong&gt; Maybe it's a frozen build you no longer maintain, or it can't be resubmitted, or there's simply no v-next on the way. Here's the freeing part: nothing you do in Keylight can break it, because that old build runs its own licensing and never calls Keylight at all. So you import those customers not to keep the old app alive — it's already fine on its own — but to make them &lt;em&gt;reachable&lt;/em&gt;. Once they're in Keylight, the &lt;a href="https://docs.keylight.dev/concepts/customer-portal/" rel="noopener noreferrer"&gt;customer portal&lt;/a&gt; can offer them the move to a new version, and the old build keeps running untouched until each customer upgrades or drifts away on their own schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your app is shipping a new version.&lt;/strong&gt; This is the more common case: v-next is going out, and you want existing customers to keep working when they update. You import their keys as they are, and those keys validate in the new build immediately — no re-issuing, no re-delivery. Whether the customer enters that key once or your app reads it for them (the section above) is your call; either way the key they've always had is the one that works.&lt;/p&gt;

&lt;p&gt;Both paths share the same backbone — import as-is, invalidate nothing — they just differ in whether the old binary is part of the picture at all. If you're unsure which is yours, the &lt;a href="https://docs.keylight.dev/migration/overview/" rel="noopener noreferrer"&gt;migration overview&lt;/a&gt; walks the decision, including the messier cases (missing key strings, very large bases) where emailing us first is the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping a major version (v1 → v15)
&lt;/h2&gt;

&lt;p&gt;A version number does a lot of psychological work here. "I'm going from v1 to v15" &lt;em&gt;sounds&lt;/em&gt; like the kind of leap that breaks things. It doesn't, and seeing why makes the whole migration feel less fragile.&lt;/p&gt;

&lt;p&gt;Say you've sold v1 through v14 over the years and v15 is the build that adopts Keylight. The sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Import the old keys.&lt;/strong&gt; Upload your customer list as a CSV; every imported license is tagged &lt;strong&gt;Migrated&lt;/strong&gt; so you can see your backlog at a glance. The keys are live immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offer the upgrade.&lt;/strong&gt; Attach an upgrade to your v15 key type. An upgrade &lt;strong&gt;swaps the key type in place and keeps the same key string&lt;/strong&gt; — a customer's v1 key simply &lt;em&gt;becomes&lt;/em&gt; a v15 key, with v15's entitlements and limits. They are not handed a brand-new key to install.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the old versions fade.&lt;/strong&gt; They keep running on their own; customers come over on their own schedule, and you're not chasing anyone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The gap between "1" and "15" never matters, because at no point is a key replaced or revoked out from under a customer. The number is just a label on the product; the key is the thing that has to keep working, and it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Migrated" is provenance, not a downgrade
&lt;/h2&gt;

&lt;p&gt;One last thing worth defusing, because it trips people up: the &lt;strong&gt;Migrated&lt;/strong&gt; label is not a penalty box.&lt;/p&gt;

&lt;p&gt;It's tempting to read it as a second-class status — a license that's "only sort of" active, or one the customer has to do something to fully claim. It isn't either. &lt;strong&gt;Migrated&lt;/strong&gt; records &lt;em&gt;where a license came from&lt;/em&gt;, full stop. It's a neutral gray pill in your dashboard so you can tell your imported backlog apart from licenses sold natively through Keylight. It imposes no restriction on the customer, expires nothing, and changes no entitlement. A migrated license and a native one are the same kind of object; one just carries a note about its origin.&lt;/p&gt;

&lt;p&gt;That's deliberate. We don't auto-remint imported customers or force any change on them, precisely because the whole promise of this migration is "your customers keep what they have." A flag that quietly downgraded them would break that promise. It doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;If this is the reassurance you were after and you're ready to actually do it, the mechanics — CSV columns, the preview-and-confirm flow, when keys are preserved — are in &lt;a href="https://keylight.dev/blog/import-existing-customer-base" rel="noopener noreferrer"&gt;How to Import an Existing Customer Base into Keylight&lt;/a&gt; and the &lt;a href="https://keylight.dev/migrate-licensing-system" rel="noopener noreferrer"&gt;migration hub&lt;/a&gt;. For the deciding-to-switch view, including the tax-versus-fees tradeoff if you're leaving a merchant of record, see &lt;a href="https://keylight.dev/blog/migrating-to-keylight" rel="noopener noreferrer"&gt;Migrating Your App's Licensing to Keylight&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Still not sure your situation is safe? Tell us what you're migrating from and we'll talk it through — &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt; and we read everything. Keylight starts at $19/month with a free tier, so you can import your base and keep your current setup running alongside it. Full details on &lt;a href="https://keylight.dev/pricing" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Will my customers' apps stop working when I migrate?
&lt;/h3&gt;

&lt;p&gt;Not if you import their keys as they are. An imported license is active immediately and validates exactly like a key you minted by hand. Your app activates that key once — the customer enters it, or your code reads it from your old storage — and from then on it works offline like any other license. A customer who never updates isn't affected at all, because their old build doesn't talk to Keylight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I have to re-issue everyone a new key?
&lt;/h3&gt;

&lt;p&gt;No. If you still have the plaintext key strings, you import them and every customer keeps the exact key they already have. You only generate new keys for customers whose original key you no longer hold.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if my old app can't ship an update?
&lt;/h3&gt;

&lt;p&gt;Then nothing you do in Keylight can break it — the old build runs its own validation and never calls Keylight. You import those customers so the portal can offer them your new version, and the old build keeps running on its own until they move.&lt;/p&gt;

</description>
      <category>desktop</category>
      <category>licensing</category>
      <category>indiedev</category>
      <category>programming</category>
    </item>
    <item>
      <title>One-Time vs Subscription Licensing: Which to Use?</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:26:51 +0000</pubDate>
      <link>https://dev.to/nicodemanez/one-time-vs-subscription-licensing-which-to-use-6oi</link>
      <guid>https://dev.to/nicodemanez/one-time-vs-subscription-licensing-which-to-use-6oi</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/one-time-vs-subscription-licensing" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Should I charge once or charge monthly?" is one of the first real decisions an indie app faces, and it is usually answered by copying whoever the founder admires rather than by what fits the product. Both models are legitimate. This post lays out when each one actually makes sense, the honest tradeoffs, and how Keylight models perpetual keys and renewing subscriptions so the licensing follows your pricing instead of constraining it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two models, defined
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;one-time (perpetual) license&lt;/strong&gt; is a single payment for a license that does not expire. The customer owns that version — and usually some agreed window of updates — forever. Think of the classic "buy version 3, use it as long as you like" desktop app.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;subscription license&lt;/strong&gt; is a recurring payment for continued access. The license is valid while the customer keeps paying; stop paying and access ends or degrades. The recurring revenue funds ongoing development and any server-side costs the app carries.&lt;/p&gt;

&lt;p&gt;The distinction is not about the dollar amount — it is about what the customer is buying: ownership of a thing, or ongoing access to a service. Get that framing right and the model usually picks itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a one-time license is the right call
&lt;/h2&gt;

&lt;p&gt;A perpetual license fits when your app is a &lt;strong&gt;tool the customer owns and runs locally&lt;/strong&gt;, with low ongoing cost to you per user. A focused Mac utility, an audio plugin, a developer tool that does its job on the user's machine — these have little marginal server cost, so charging rent for access is hard to justify and customers feel it.&lt;/p&gt;

&lt;p&gt;One-time pricing also builds trust. There is no metering, no "what happens if I stop paying," no fear of being locked out of work they already did. For tools people depend on, that ownership feeling is a genuine selling point, and it is exactly the kind of &lt;a href="https://keylight.dev/blog/indie-mac-app-pricing-2026" rel="noopener noreferrer"&gt;no-value-extraction&lt;/a&gt; stance that earns goodwill with developers and power users.&lt;/p&gt;

&lt;p&gt;The tradeoff is honest: revenue is lumpy and front-loaded. You get paid once per customer, so growth depends on continually reaching new buyers, and a quiet month is a lean month. The usual answer is paid major upgrades — version 4 is a new purchase — which we will come back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a subscription is the right call
&lt;/h2&gt;

&lt;p&gt;A subscription fits when your app is genuinely a &lt;strong&gt;service&lt;/strong&gt;, not just a binary. If you run sync infrastructure, host an API, ship inference that costs you per call, or provide content and models that need continuous updates, those costs recur — so the revenue should too. Charging once for a product that costs you every month to keep running is how indie apps quietly go underwater.&lt;/p&gt;

&lt;p&gt;Subscriptions also suit apps under &lt;strong&gt;continuous, fast development&lt;/strong&gt;, where customers are paying for an evolving product rather than a fixed version. The recurring revenue is what lets a solo developer keep working on it full-time.&lt;/p&gt;

&lt;p&gt;The tradeoff, again honestly: subscriptions ask more of the customer and carry an implicit promise. People resist renting software that feels like it should be owned, and a subscription obligates you to keep delivering — let development stall and churn follows fast. Subscribe-to-use should map to a real, ongoing service. If you are reaching for a subscription only because the recurring revenue looks nicer on a chart, that is the &lt;a href="https://keylight.dev/blog/hidden-cost-of-free-licensing" rel="noopener noreferrer"&gt;SaaS playbook&lt;/a&gt; pointed at a product that does not need it, and customers can tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Keylight models each
&lt;/h2&gt;

&lt;p&gt;The point of a good licensing layer is that it expresses your pricing rather than dictating it. Both models are the same signed lease with a different expiry.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;perpetual license&lt;/strong&gt; is a lease with &lt;code&gt;expiresAt&lt;/code&gt; set to null. It verifies offline forever; nothing time-bound ever ends it. The only thing that revokes it is an explicit action — most often &lt;a href="https://keylight.dev/blog/refunds-in-licensed-software" rel="noopener noreferrer"&gt;a refund&lt;/a&gt;, which marks the key revoked so the app rejects it on the next revalidation. This is the model behind &lt;a href="https://keylight.dev/perpetual-licenses" rel="noopener noreferrer"&gt;perpetual licenses&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;subscription license&lt;/strong&gt; is a lease whose &lt;code&gt;expiresAt&lt;/code&gt; is tied to the Stripe billing period. When &lt;a href="https://keylight.dev/blog/stripe-webhooks-macos-license-keys" rel="noopener noreferrer"&gt;Stripe reports a renewal&lt;/a&gt;, Keylight reissues the lease with a later expiry; the app picks up the extension on its next revalidation. If the subscription lapses, the lease simply expires — and Keylight's default is to drop the customer to a free or expired state, not to hard-lock the app, so a failed card does not lock someone out of their own work mid-task.&lt;/p&gt;

&lt;p&gt;Because both are just leases, switching models or running both is a configuration choice, not a re-architecture. Your app reads the same license state machine either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hybrid most indie apps actually want
&lt;/h2&gt;

&lt;p&gt;In practice many indie apps want neither pure model — they want a &lt;strong&gt;perpetual license with a bounded updates window&lt;/strong&gt;. The customer pays once, owns the version they bought forever, and gets updates for, say, a year; after that the app keeps working indefinitely, but new versions are a fresh purchase. It is the "fair perpetual" model: customers are never locked out of what they paid for, and you still have a recurring reason for them to pay again.&lt;/p&gt;

&lt;p&gt;This maps cleanly onto leases. The perpetual entitlement never expires; the updates window is its own expiry your app checks before offering a newer build. The customer's app never stops working — only the "free updates" entitlement lapses. You get a renewal motion without the rented-software resentment a hard subscription can create. For a deeper treatment of the subscription mechanics specifically — renewals, upgrades, and downgrades — see &lt;a href="https://keylight.dev/blog/subscription-licensing-mac-apps" rel="noopener noreferrer"&gt;subscription licensing for Mac apps&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;There is no universally correct answer, only a fit. Own-it tools lean one-time; real services lean subscription; a lot of indie apps are happiest in the fair-perpetual middle. Decide based on what the customer is actually buying and what the app costs you to run — and let the &lt;a href="https://keylight.dev/pricing" rel="noopener noreferrer"&gt;licensing layer&lt;/a&gt; express that decision rather than push you toward whichever model bills more. If you are weighing this for your own app, &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt; and we'll think it through with you.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is a one-time license or a subscription better for an indie app?
&lt;/h3&gt;

&lt;p&gt;Neither is universally better. One-time fits focused tools customers buy and own; subscriptions fit apps with ongoing server costs or continuous development. Many indie apps land on a hybrid: a perpetual license with a bounded window of free updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does Keylight model a perpetual license?
&lt;/h3&gt;

&lt;p&gt;As a lease with no expiry — the expiresAt field is null. The signed lease verifies offline forever; the only thing that ends it is an explicit revocation, such as after a refund.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does Keylight model a subscription?
&lt;/h3&gt;

&lt;p&gt;The lease carries an expiry tied to the Stripe subscription period. On renewal the lease is reissued with a later expiry; if the subscription lapses, the lease expires and the app moves to an expired or free state rather than hard-locking.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>licensing</category>
      <category>indiedev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Stripe vs Paddle vs Lemon Squeezy for Indie Developers</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:26:46 +0000</pubDate>
      <link>https://dev.to/nicodemanez/stripe-vs-paddle-vs-lemon-squeezy-for-indie-developers-1gkc</link>
      <guid>https://dev.to/nicodemanez/stripe-vs-paddle-vs-lemon-squeezy-for-indie-developers-1gkc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/stripe-vs-paddle-vs-lemon-squeezy" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you are an indie developer about to charge for an app, you have three names in front of you: Stripe, Paddle, and Lemon Squeezy. They get discussed as if they are the same kind of choice. They are not. This post lays out the real differences so you can pick deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  They are not the same kind of thing
&lt;/h2&gt;

&lt;p&gt;The single most important distinction is processor versus merchant of record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe is a payment processor.&lt;/strong&gt; It moves money from your customer's card to your bank account. You are the legal seller. The customer relationship, and the sales-tax liability, are yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paddle and Lemon Squeezy are merchants of record.&lt;/strong&gt; They are the legal seller of your product. The customer's receipt comes from them. They calculate, collect, and remit sales tax and VAT worldwide, then pay you the remainder. You are, in effect, a supplier selling through their storefront.&lt;/p&gt;

&lt;p&gt;Everything else — fees, customer ownership, lock-in — flows from this one difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fees
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stripe:&lt;/strong&gt; about 2.9% + $0.30 per card charge (US card pricing; rates vary by country, card type, and Stripe products used). On a $49 sale, roughly $1.72.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paddle:&lt;/strong&gt; approximately 5% plus the underlying payment fee. On that $49 sale, closer to $2.45-$2.75.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lemon Squeezy:&lt;/strong&gt; 5% + $0.50 per transaction. On $49, roughly $2.95.&lt;/p&gt;

&lt;p&gt;Stripe is plainly the cheapest on raw processing. But the merchant-of-record fee is not pure overhead — it is buying tax compliance. To compare honestly, you have to price that in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tax compliance: the real tradeoff
&lt;/h2&gt;

&lt;p&gt;Paddle and Lemon Squeezy handle sales tax and VAT for you, everywhere. If you sell to a customer in Germany, Australia, or California, they apply the right rate, collect it, and remit it. You never think about it.&lt;/p&gt;

&lt;p&gt;With Stripe, that is your responsibility. The practical answer is Stripe Tax — built into Stripe Checkout, about 0.5% of the transaction — or a third-party tool like Quaderno or TaxJar. These work well, but they require setup and occasional maintenance as rules change.&lt;/p&gt;

&lt;p&gt;So the fair comparison is not 2.9% vs 5%. It is roughly &lt;strong&gt;2.9% + 0.5% tax + a small licensing cost&lt;/strong&gt; for the Stripe path, versus &lt;strong&gt;~5%&lt;/strong&gt; all-in for a merchant of record. At meaningful revenue the Stripe path is still cheaper — and below tax thresholds, cheaper still. But it is not hands-off the way a merchant of record is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Customer ownership
&lt;/h2&gt;

&lt;p&gt;With Stripe you own the customer: email, billing history, subscription status, all exportable. You can email customers, run win-back campaigns, and migrate processors if you ever need to.&lt;/p&gt;

&lt;p&gt;With Paddle or Lemon Squeezy, the customer is theirs. Your invoice shows their name. How much customer data flows back to you is governed by their terms, and those terms can change. Migrating away later is a renegotiation, not an export.&lt;/p&gt;

&lt;p&gt;For a product you plan to support for years, an owned customer list is a genuine business asset. For a first product or a small catalogue, the difference matters less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Licensing is a separate question
&lt;/h2&gt;

&lt;p&gt;Here is the part the comparison usually misses: &lt;strong&gt;none of these three issues license keys&lt;/strong&gt; in the sense a desktop app needs.&lt;/p&gt;

&lt;p&gt;A desktop app needs a key it can verify &lt;em&gt;offline&lt;/em&gt; — a cryptographically signed entitlement, checked locally with no server call. Stripe has nothing for this; it is a payment processor and stops at the payment. Paddle and Lemon Squeezy each have a license-key feature, but it is tied to their platform and oriented around an online validation API, not offline signature verification.&lt;/p&gt;

&lt;p&gt;So whichever of the three you choose for payments, licensing is a separate layer. The clean architecture is: a processor for payments, and a licensing layer for keys, connected by webhooks. In code, the licensing half looks the same regardless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;KeylightSDK&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;licensing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkOnLaunch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;licensing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;licensed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nf"&gt;enablePaidFeatures&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nf"&gt;showTrialBanner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;daysLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nf"&gt;showRenewalPrompt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;invalid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nf"&gt;showActivationSheet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which to choose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Stripe&lt;/strong&gt; if you want the lowest fees, full customer ownership, and you are willing to add a tax tool. At any meaningful revenue this is the strongest long-term position. Pair it with a licensing layer for keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Paddle or Lemon Squeezy&lt;/strong&gt; if you want the simplest possible setup, you sell globally from day one, and you genuinely do not want to think about tax. You pay more and give up the customer list in exchange for never handling compliance. Between the two, Lemon Squeezy leans toward a friendlier indie experience; Paddle toward more established tooling.&lt;/p&gt;

&lt;p&gt;For most indie developers building a paid app to support for years, Stripe plus a licensing layer wins. For a first product where simplicity beats everything, a merchant of record is a defensible call. If you would like a deeper comparison on any one of these, &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which is cheapest: Stripe, Paddle, or Lemon Squeezy?
&lt;/h3&gt;

&lt;p&gt;Stripe has the lowest processing fee at about 2.9% + $0.30. Paddle and Lemon Squeezy charge around 5% because their fee bundles global tax compliance. Adding a tax tool to Stripe usually still totals less.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between a payment processor and a merchant of record?
&lt;/h3&gt;

&lt;p&gt;A payment processor like Stripe moves money while you remain the legal seller. A merchant of record like Paddle or Lemon Squeezy is the legal seller, assuming sales tax and VAT liability on your behalf.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do any of them handle license keys?
&lt;/h3&gt;

&lt;p&gt;None of them issue cryptographically signed license keys an app verifies offline. Paddle and Lemon Squeezy offer platform-tied key APIs; Stripe has none. Licensing is a separate layer.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>payments</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>Licensing a Cross-Platform App from One Control Plane</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:26:42 +0000</pubDate>
      <link>https://dev.to/nicodemanez/licensing-a-cross-platform-app-from-one-control-plane-4oli</link>
      <guid>https://dev.to/nicodemanez/licensing-a-cross-platform-app-from-one-control-plane-4oli</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/cross-platform-licensing-one-control-plane/" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you ship the same product on more than one platform — a native Mac build, an Electron or Tauri desktop app, a Rust CLI — the licensing question is not "how do I license each one?" It is "how do I license all of them without running three disconnected systems?" The answer is a single control plane: one tenant that owns your customers, keys, and device limits, with thin SDKs in each app that all read the same signed lease. This post is the build guide for that setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "one control plane" means
&lt;/h2&gt;

&lt;p&gt;A control plane is the place the licensing truth lives. With Keylight it is your &lt;strong&gt;tenant&lt;/strong&gt;: one account that holds every customer record, every license key, the activation count and limit on each key, the entitlements each key grants, and the Stripe connection that mints keys on purchase. The dashboard, the revocation controls, the activation resets — all of it operates on that one set of records.&lt;/p&gt;

&lt;p&gt;The SDKs are deliberately not the control plane. They are clients. Each one does the same three things in its own language: exchange a key for a signed lease, verify that lease locally, and read entitlements from it. None of them holds authority over whether a license is valid — they verify a document the control plane signed. That separation is the whole point. It means a customer who buys once exists once, and a device limit of three means three devices total, not three per platform.&lt;/p&gt;

&lt;p&gt;This is the &lt;a href="https://keylight.dev/features/license-keys" rel="noopener noreferrer"&gt;universal licensing layer&lt;/a&gt; idea made concrete: licensing is infrastructure you configure once, not a backend you rebuild per app.&lt;/p&gt;

&lt;h2&gt;
  
  
  One lease format, every SDK
&lt;/h2&gt;

&lt;p&gt;What makes a single control plane work across languages is that the &lt;strong&gt;lease format is shared&lt;/strong&gt;. A Keylight lease is an Ed25519-signed document with a fixed wire format. Signing happens once, server-side; every SDK reconstructs and verifies that same format against the tenant's public keys. The Swift SDK and the Rust crate are not two implementations of "a license" — they are two readers of one signed artifact.&lt;/p&gt;

&lt;p&gt;The practical consequence: the SDKs are at parity by construction. The same license states exist everywhere — &lt;code&gt;Licensed&lt;/code&gt;, &lt;code&gt;Trial&lt;/code&gt;, &lt;code&gt;Limited&lt;/code&gt;, &lt;code&gt;FreeTier&lt;/code&gt;, &lt;code&gt;Expired&lt;/code&gt;, &lt;code&gt;Invalid&lt;/code&gt; — because they are derived from the same lease fields. &lt;code&gt;hasEntitlement("pro")&lt;/code&gt; means the identical thing in Swift, JavaScript, and Rust, because all three are checking the same signed &lt;code&gt;entitlements&lt;/code&gt; field. For the field-by-field anatomy of that shared document, see &lt;a href="https://keylight.dev/blog/ed25519-lease-format-explained" rel="noopener noreferrer"&gt;what is inside a Keylight lease&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring every SDK to one tenant
&lt;/h2&gt;

&lt;p&gt;Here is the same product, configured against one tenant in each of Keylight's SDKs. Notice that the &lt;code&gt;tenantId&lt;/code&gt; and &lt;code&gt;productId&lt;/code&gt; are identical in every one — that is what binds them to the same control plane.&lt;/p&gt;

&lt;p&gt;The native Mac build, in Swift, via the &lt;code&gt;Keylight.manager&lt;/code&gt; factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;licensing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;Keylight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;sdkKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"sdk_live_…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"your-tenant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"your-product"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;keyPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"PROD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;trustedPublicKeyBase64&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;tenant public key&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;trialDurationDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;branding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;appName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"My App"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;purchaseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://example.com/buy"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;supportEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"support@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;tintColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accentColor&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Tauri desktop build, registered in the Rust core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;keylight&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;KeylightConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;KeylightConfig&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"your-tenant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"your-product"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sdk_live_…"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nn"&gt;tauri&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.plugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;tauri_plugin_keylight&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Rust CLI or a non-Tauri Rust app, using the crate directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;keylight&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Keylight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KeylightConfig&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;KeylightConfig&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"your-tenant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"your-product"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sdk_live_…"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;kl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Keylight&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kl&lt;/span&gt;&lt;span class="nf"&gt;.activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USER-LICENSE-KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And an Electron build, with the JavaScript SDK in the main process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Keylight&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your-tenant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your-product&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four apps, one tenant. A customer's key activates on their Mac app and their CLI, and both activations count against the same limit. Revoke that key once in the dashboard and every app rejects it on the next revalidation. There are per-platform integration walkthroughs in &lt;a href="https://keylight.dev/blog/add-license-keys-swift-macos-app" rel="noopener noreferrer"&gt;how to add license keys to a Swift macOS app&lt;/a&gt;, &lt;a href="https://keylight.dev/blog/add-license-keys-electron-app" rel="noopener noreferrer"&gt;an Electron app&lt;/a&gt;, and &lt;a href="https://keylight.dev/blog/add-license-keys-tauri-app" rel="noopener noreferrer"&gt;a Tauri app&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lands today, honestly
&lt;/h2&gt;

&lt;p&gt;The control plane is real and shared, but it is worth being precise about what "cross-platform" covers right now rather than overselling it. The shipped SDKs are &lt;strong&gt;Swift&lt;/strong&gt; (Apple platforms), &lt;strong&gt;JavaScript/TypeScript&lt;/strong&gt; (web, Node, Electron, edge), &lt;strong&gt;Rust&lt;/strong&gt;, and the first-party &lt;strong&gt;Tauri plugin&lt;/strong&gt;. Those cover the bulk of where independent desktop apps ship today.&lt;/p&gt;

&lt;p&gt;What this is not, yet, is a drop-in REST API you can call from any language that lacks a native SDK. Additional language SDKs are added based on real demand, not to fill a matrix. The design makes that cheap: because the lease format and the control plane are shared, a new SDK is another reader of the same document, not a new system. So the honest framing is "one control plane across the SDKs that exist" — which is most of the market — rather than "licenses any language anywhere." When you only ship on platforms with a Keylight SDK, the single-tenant model already holds end to end.&lt;/p&gt;




&lt;p&gt;A cross-platform app does not need cross-platform licensing complexity. It needs one place the licensing truth lives and thin, identical clients in each app reading the same signed lease. Configure the tenant once, point each SDK at it, and the platform count stops being a licensing problem. If you are wiring up a stack this post does not cover, &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt; and we'll help.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can one license system cover apps written in different languages?
&lt;/h3&gt;

&lt;p&gt;Yes. Keylight issues the same Ed25519-signed lease format regardless of which SDK reads it. The Swift, JavaScript, Rust, and Tauri SDKs all verify that one format, so a single tenant covers a Mac app, an Electron app, and a Rust CLI at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do customers and device limits carry across platforms?
&lt;/h3&gt;

&lt;p&gt;Yes — they live on the tenant, not in the SDK. A customer, their key, its activation count, and its entitlements are one record. Whichever SDK activates a device increments the same count against the same limit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which platforms does Keylight cover today?
&lt;/h3&gt;

&lt;p&gt;Shipped SDKs are Swift (Apple), JavaScript/TypeScript (web, Node, Electron), Rust, and a first-party Tauri plugin. More language SDKs are added based on demand; the lease format and control plane are shared, so new SDKs slot into the same tenant.&lt;/p&gt;

</description>
      <category>desktop</category>
      <category>programming</category>
      <category>licensing</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>Offline License Validation in Rust</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:26:37 +0000</pubDate>
      <link>https://dev.to/nicodemanez/offline-license-validation-in-rust-617</link>
      <guid>https://dev.to/nicodemanez/offline-license-validation-in-rust-617</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/offline-license-validation-rust/" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A license check that needs a server is a license check that fails on a plane. For desktop and CLI apps that is unacceptable, so Keylight makes the license itself verifiable offline: it is an Ed25519-signed document your app checks locally with only a bundled public key. This post walks through how that works in Rust with the &lt;a href="https://keylight.dev/licensing-for-rust-apps" rel="noopener noreferrer"&gt;keylight crate&lt;/a&gt; — the synchronous verifier, the trusted keys, and the cached lease that keeps an app licensed without a connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of offline validation
&lt;/h2&gt;

&lt;p&gt;There are two distinct jobs in any licensing system, and the mistake most DIY implementations make is fusing them. The first job is &lt;strong&gt;verification&lt;/strong&gt;: is this license genuine and unexpired? The second is &lt;strong&gt;revalidation&lt;/strong&gt;: has anything changed server-side — a refund, a downgrade, a revocation — since the last time we checked?&lt;/p&gt;

&lt;p&gt;Verification can and should be local. A Keylight lease is a small JSON document signed with Keylight's Ed25519 private key. Your app ships with the matching public key, so it can confirm the lease is authentic and untampered using only math, in microseconds, with no connection. Revalidation is the only part that needs the network, and it is periodic and can happen in the background — never on the critical path of launching the app.&lt;/p&gt;

&lt;p&gt;Splitting them is what makes offline-first possible. The app verifies the cached lease at startup and unlocks immediately; it reaches out to revalidate only when a check is actually due. For the broader model, see &lt;a href="https://keylight.dev/blog/how-offline-license-validation-works" rel="noopener noreferrer"&gt;how offline license validation works&lt;/a&gt;; for the anatomy of the signed document itself, &lt;a href="https://keylight.dev/blog/ed25519-lease-format-explained" rel="noopener noreferrer"&gt;what is inside a Keylight lease&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the keylight crate
&lt;/h2&gt;

&lt;p&gt;The crate is synchronous — it uses blocking HTTP via &lt;code&gt;ureq&lt;/code&gt;, so there is no tokio runtime to set up. Add it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo add keylight
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build a config with your tenant and product IDs, pull the tenant's public keyset so leases verify offline, and create the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;keylight&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Keylight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KeylightConfig&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;KeylightConfig&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"your-tenant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"your-product"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sdk_live_…"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.max_offline_days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// bound how long a cached lease is accepted offline&lt;/span&gt;
        &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Fetch the tenant's trusted Ed25519 keyset. You can also pin keys at build&lt;/span&gt;
    &lt;span class="c1"&gt;// time with .trusted_key(kid, pub_b64) if you'd rather not fetch them.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;keylight&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;keyset&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fetch_keyset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;keylight&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ureq_transport&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;UreqTransport&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="py"&gt;.base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="py"&gt;.tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="py"&gt;.trusted_keys&lt;/span&gt;&lt;span class="nf"&gt;.extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;kl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Keylight&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// The one online step: exchange a key for a signed lease. The lease is&lt;/span&gt;
    &lt;span class="c1"&gt;// Ed25519-verified *before* it is written to disk.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kl&lt;/span&gt;&lt;span class="nf"&gt;.activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USER-LICENSE-KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"activated: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="py"&gt;.activated&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// From here on, gating works offline from the cached lease.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kl&lt;/span&gt;&lt;span class="nf"&gt;.has_entitlement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pro"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Pro features unlocked"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;activate&lt;/code&gt; is the only call here that touches the network. Once it succeeds, the verified lease is persisted, and everything after that — entitlement checks, state resolution — reads from the cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying a lease with no network call
&lt;/h2&gt;

&lt;p&gt;The crate exposes the verifier as a standalone function, so you can check a lease yourself — in a CLI, in your own gating logic, or server-side — without constructing a client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;keylight&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;verify_lease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SKEW_SECONDS&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_lease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// the Lease you hold&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;trusted_keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// HashMap&amp;lt;kid, base64 ed25519 public key&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;now_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// current unix time&lt;/span&gt;
    &lt;span class="n"&gt;SKEW_SECONDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// 300s tolerance for clock drift&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="nf"&gt;.is_trusted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="py"&gt;.expired&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Genuine, unexpired lease — unlock.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;verify_lease&lt;/code&gt; returns a &lt;code&gt;VerifyResult&lt;/code&gt; with three independent booleans: &lt;code&gt;kid_known&lt;/code&gt; (the lease was signed by a key your app trusts), &lt;code&gt;signature_valid&lt;/code&gt; (the Ed25519 signature checks out), and &lt;code&gt;expired&lt;/code&gt; (the lease is past its expiry window, accounting for clock skew). &lt;code&gt;is_trusted()&lt;/code&gt; is the conjunction of the first two — a lease can be authentic but expired, and the struct lets you tell those cases apart.&lt;/p&gt;

&lt;p&gt;This is the whole offline story in one function call: no HTTP, no I/O beyond reading the lease you already have. The trust anchor is the tenant's private key staying on Keylight's servers; an attacker who extracts the public key from your binary still cannot mint a lease that &lt;code&gt;signature_valid&lt;/code&gt; accepts.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the client stays valid offline
&lt;/h2&gt;

&lt;p&gt;In normal use you do not call &lt;code&gt;verify_lease&lt;/code&gt; directly — the client does it for you. &lt;code&gt;state()&lt;/code&gt; resolves the current license state with no network call, reading the cached lease and re-verifying it each time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;keylight&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;LicenseState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;kl&lt;/span&gt;&lt;span class="nf"&gt;.state&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;LicenseState&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Licensed&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* full access */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nn"&gt;LicenseState&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Trial&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;days_left&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{days_left} days left"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nn"&gt;LicenseState&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Limited&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* on the offline grace window */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nn"&gt;LicenseState&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;FreeTier&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* free features only */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nn"&gt;LicenseState&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Expired&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* renew */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nn"&gt;LicenseState&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Invalid&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* prompt for a key */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cached lease lives in a device-bound encrypted file store (ChaCha20-Poly1305), in the per-OS application data directory, so it survives restarts and is the same code path on macOS, Windows, and Linux. The encryption is a hardening measure, not the trust boundary — the Ed25519 signature is what makes the lease unforgeable. Even decrypted, a lease edited to grant more entitlements fails &lt;code&gt;signature_valid&lt;/code&gt; and is rejected.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;max_offline_days&lt;/code&gt; setting is what stops a device from drifting offline forever. When set, the client refuses a cached lease whose last successful online validation is older than that window, even if the lease itself has not expired. It is the honest middle ground between fail-closed (a server call on every launch, which breaks offline use) and fail-open (a cached lease accepted indefinitely): legitimate offline use keeps working for the window you choose, and the app revalidates when it can. At launch, &lt;code&gt;kl.check_on_launch()&lt;/code&gt; triggers that background revalidation only when one is due.&lt;/p&gt;




&lt;p&gt;That is offline license validation in Rust end to end: one online activation, then local Ed25519 verification on every check, bounded by a grace window you control. The same crate also backs the &lt;a href="https://keylight.dev/blog/add-license-keys-tauri-app" rel="noopener noreferrer"&gt;Tauri plugin&lt;/a&gt;, so a Tauri app and a plain Rust binary verify leases the same way. If your Rust setup has an angle this post does not cover, &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt; and we'll extend it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I validate a license in Rust without a network connection?
&lt;/h3&gt;

&lt;p&gt;Yes. A Keylight lease is an Ed25519-signed document. The keylight crate verifies the signature locally against the tenant's public keys with verify_lease — no HTTP call. The network is only needed for the initial activation and periodic revalidation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the keylight Rust crate async?
&lt;/h3&gt;

&lt;p&gt;No. It is synchronous and uses blocking HTTP via ureq, so it works in a plain Rust binary or CLI without a tokio runtime. The offline verification path makes no network call at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long can a Rust app stay offline before the license stops working?
&lt;/h3&gt;

&lt;p&gt;Until the lease itself expires, unless you set max_offline_days. That bounds how long the cached lease is accepted since the last successful online validation, so a device cannot stay offline indefinitely.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>security</category>
      <category>desktop</category>
      <category>licensing</category>
    </item>
    <item>
      <title>Tauri vs Electron for Licensed Desktop Apps</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:26:32 +0000</pubDate>
      <link>https://dev.to/nicodemanez/tauri-vs-electron-for-licensed-desktop-apps-n7b</link>
      <guid>https://dev.to/nicodemanez/tauri-vs-electron-for-licensed-desktop-apps-n7b</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/tauri-vs-electron-licensed-desktop-apps/" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you are picking between Tauri and Electron for an app you intend to sell, the licensing implications rarely make the comparison — but they should, because both frameworks have a clean place to put a license check and the choice shapes where it goes. This post covers the real differences between the two, then where verification belongs in each, and why &lt;a href="https://keylight.dev/features/license-keys" rel="noopener noreferrer"&gt;Keylight&lt;/a&gt; licenses both the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual architectural difference
&lt;/h2&gt;

&lt;p&gt;Electron bundles a full Chromium runtime and a Node.js process into every app. Your UI is a web page rendered by that bundled Chromium; your backend logic runs in the Node &lt;strong&gt;main process&lt;/strong&gt;. You ship the browser with the app, which is why every Electron app behaves identically everywhere — and why every Electron app is large.&lt;/p&gt;

&lt;p&gt;Tauri takes the opposite approach to rendering: it uses the operating system's &lt;strong&gt;native webview&lt;/strong&gt; (WebView2 on Windows, WebKit on macOS and Linux) instead of bundling a browser, and the app core is &lt;strong&gt;Rust&lt;/strong&gt;. Your UI is still a web frontend, but the binary does not carry a browser, and your native logic is compiled Rust rather than Node.&lt;/p&gt;

&lt;p&gt;So the headline difference is two things at once: what renders the UI (bundled Chromium vs the system webview) and what language the core is (Node vs Rust). Everything else in this comparison flows from those two facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundle size, performance, and maturity
&lt;/h2&gt;

&lt;p&gt;The honest tradeoffs, without picking a side:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bundle size and memory.&lt;/strong&gt; Tauri wins clearly here. A minimal Tauri app is a few megabytes; a minimal Electron app is tens to over a hundred, because it ships Chromium. Tauri's use of the system webview also tends to mean lower idle memory. If download size or footprint matters to your audience, this is real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistency and maturity.&lt;/strong&gt; Electron wins here. Bundling Chromium means your app renders the same on every machine, with no surprises from an older system WebKit on someone's Linux box. Electron is also older, with a deeper ecosystem, more native modules, and more answered Stack Overflow questions. For a complex app, that maturity has value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Language of the core.&lt;/strong&gt; This is a preference, not a winner. Electron's core is Node, so your whole app is JavaScript/TypeScript end to end. Tauri's core is Rust, which is more to learn if you do not know it, but gives you a fast, memory-safe native layer. For licensing specifically, the Rust core turns out to be a small advantage — covered next.&lt;/p&gt;

&lt;p&gt;Neither framework is wrong for a paid app. Tauri leans lighter and more modern; Electron leans more proven and uniform. Pick on the merits of your app; the licensing layer is good on both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where license verification lives in each
&lt;/h2&gt;

&lt;p&gt;The one rule that matters for both frameworks: &lt;strong&gt;do not verify in the renderer&lt;/strong&gt;. The renderer is a web page. A user can open DevTools, read your tenant configuration, inspect variables, and watch network traffic. License logic in the renderer is license logic the user can see and poke at. So in both frameworks the check belongs in the privileged process, with only the result crossing to the UI.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Tauri&lt;/strong&gt;, that privileged place is the Rust core. The Keylight plugin runs activation and verification in Rust and exposes three commands — &lt;code&gt;activate&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt;, &lt;code&gt;hasEntitlement&lt;/code&gt; — to the frontend over Tauri's command bridge. The verified lease and your config never enter the webview; the frontend only ever receives booleans. Because the core is compiled Rust rather than interpreted JavaScript, it is also meaningfully harder to patch.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Electron&lt;/strong&gt;, that privileged place is the Node main process. The JavaScript SDK runs there, and you expose a small surface to the renderer over IPC with &lt;code&gt;ipcMain.handle&lt;/code&gt;, bridged through a &lt;code&gt;contextBridge&lt;/code&gt; preload. The tenant config and the verified lease stay in Node, out of the renderer's reach.&lt;/p&gt;

&lt;p&gt;The architectures rhyme: privileged process owns the lease, UI reads the result over a bridge. Tauri's edge is that its privileged process is compiled Rust; Electron's is a Node process, which is still fully out of the renderer's reach but is interpreted rather than compiled. Neither is DRM — a determined attacker with a disassembler can patch any client — but both stop the casual abuse that a renderer-side check invites. For the limits of what client-side verification can promise, see &lt;a href="https://keylight.dev/blog/ed25519-lease-format-explained" rel="noopener noreferrer"&gt;what is inside a Keylight lease&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Keylight licenses both
&lt;/h2&gt;

&lt;p&gt;Whichever you choose, the licensing setup is the same shape because both SDKs read the same Ed25519-signed lease from the same tenant. For Tauri, register the &lt;a href="https://keylight.dev/blog/add-license-keys-tauri-app" rel="noopener noreferrer"&gt;first-party plugin&lt;/a&gt; in your Rust core and call its three commands from the frontend. For Electron, run the &lt;a href="https://keylight.dev/blog/add-license-keys-electron-app" rel="noopener noreferrer"&gt;JavaScript SDK in the main process&lt;/a&gt; and bridge it to the renderer over IPC. Both verify the lease offline against your tenant's public keys, both gate features by entitlement, and both report activations against one device limit.&lt;/p&gt;

&lt;p&gt;That means the framework decision and the licensing decision are independent. You can choose Tauri or Electron purely on bundle size, ecosystem, and whether you want a Rust core — and your customers, keys, and device limits live in the same &lt;a href="https://keylight.dev/blog/cross-platform-licensing-one-control-plane" rel="noopener noreferrer"&gt;control plane&lt;/a&gt; either way. If you later ship the same product on the other framework, or add a native Mac build, they all share that one tenant. There is more framework-specific detail on the &lt;a href="https://keylight.dev/licensing-for-tauri-apps" rel="noopener noreferrer"&gt;Licensing for Tauri Apps&lt;/a&gt; and &lt;a href="https://keylight.dev/licensing-for-electron-apps" rel="noopener noreferrer"&gt;Licensing for Electron Apps&lt;/a&gt; pages.&lt;/p&gt;




&lt;p&gt;Tauri and Electron are both solid foundations for a paid desktop app. Tauri is lighter and gives you a Rust core that is a natural home for a tamper-resistant check; Electron is more mature and keeps everything in one language while still isolating the check in the main process. Choose on your app's needs — the license layer works the same on both. If your decision hinges on something this post did not weigh, &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Tauri or Electron better for a paid desktop app?
&lt;/h3&gt;

&lt;p&gt;Both ship cross-platform paid apps fine. Tauri produces smaller, lighter binaries and gives you a Rust core that is a natural place to put tamper-sensitive license checks. Electron is more mature with a larger ecosystem and a Node main process that is equally out of reach of the UI. The licensing story is strong on both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where should license verification run in each framework?
&lt;/h3&gt;

&lt;p&gt;Out of the renderer either way. In Tauri, verify in the Rust core via the plugin. In Electron, verify in the Node main process. Both keep your tenant config and the verified lease away from the DevTools console, with the UI reading the result over the bridge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Keylight support both Tauri and Electron?
&lt;/h3&gt;

&lt;p&gt;Yes. The Tauri plugin verifies in the Rust core; the JavaScript SDK runs in the Electron main process. Both read the same Ed25519-signed lease from the same tenant, so the choice of framework does not change your licensing setup.&lt;/p&gt;

</description>
      <category>tauri</category>
      <category>electron</category>
      <category>desktop</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to Prevent License Key Sharing</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:26:28 +0000</pubDate>
      <link>https://dev.to/nicodemanez/how-to-prevent-license-key-sharing-4dm7</link>
      <guid>https://dev.to/nicodemanez/how-to-prevent-license-key-sharing-4dm7</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/prevent-license-key-sharing/" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once an app has paying customers, one key inevitably shows up in a forum or a group chat, working for everyone who pastes it. The instinct is to clamp down hard. But aggressive anti-sharing measures hurt honest customers far more reliably than they hurt sharers. This post is about preventing key sharing in a way that does not make your real customers collateral damage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accept what you cannot prevent
&lt;/h2&gt;

&lt;p&gt;Start with an honest premise: you cannot make key sharing impossible. A determined person with one machine can always run one legitimate-looking copy. Any scheme that claims otherwise is either lying or about to break something for paying customers.&lt;/p&gt;

&lt;p&gt;So the goal is not "zero sharing." The goal is to make a license key behave like &lt;strong&gt;what it is meant to be&lt;/strong&gt; — a license for one person and their handful of devices — rather than a master key to your product. That is an achievable, well-defined target. "Stop all piracy" is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism: device activation limits
&lt;/h2&gt;

&lt;p&gt;The tool that actually works is the &lt;strong&gt;device activation limit&lt;/strong&gt;. Each key is valid for a bounded number of devices. When the app runs on a machine for the first time, it &lt;em&gt;activates&lt;/em&gt;: it registers that device with the licensing server, which checks the count against the limit and admits or refuses it.&lt;/p&gt;

&lt;p&gt;Devices are recognised by a &lt;strong&gt;fingerprint&lt;/strong&gt; — a stable identifier derived from the machine's hardware and install characteristics, hashed before it leaves the device so the server stores an opaque token, not a description of anyone's computer. The fingerprint's only job is equality: same device, or new device?&lt;/p&gt;

&lt;p&gt;This is what stops the forum-posted key. The key still works — but only on the first N machines that activate it. The hundred-and-first install is refused. Sharing is capped at the limit you set, instead of being unlimited. The mechanics are covered in full in the &lt;a href="https://keylight.dev/app-activation-system" rel="noopener noreferrer"&gt;app activation system guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why hard blocks backfire
&lt;/h2&gt;

&lt;p&gt;Here is where most anti-sharing schemes go wrong: they treat every ambiguous signal as guilt and respond with a hard block.&lt;/p&gt;

&lt;p&gt;A customer reinstalls macOS and their fingerprint shifts slightly — hard block, "this license is invalid." A customer replaces a drive — hard block. A customer is the &lt;em&gt;first&lt;/em&gt; person to legitimately install on their new laptop, but the key is at its limit because the old one was never removed — hard block.&lt;/p&gt;

&lt;p&gt;Every one of those is a paying customer hitting a wall you built for pirates. They do not experience it as anti-piracy; they experience it as your app being broken and hostile. You will lose more goodwill from these false positives than you ever lose revenue to the actual sharers, because there are far more honest customers than dishonest ones.&lt;/p&gt;

&lt;p&gt;The fix is two principles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be generous.&lt;/strong&gt; A limit of one device is hostile — most people have a laptop and a desktop. Three to five for a personal license stops a key being shared across an organisation while being invisible to any real customer. The limit should constrain &lt;em&gt;sharing&lt;/em&gt;, not normal multi-machine use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Degrade gracefully.&lt;/strong&gt; When something is ambiguous, do not hard-block. If a periodic re-check cannot reach the server, keep running on the last valid license — do not lock the customer out over your own outage. Reserve the hard "no" for the unambiguous case: a genuinely new device, genuinely past a generous limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deactivation is the other half
&lt;/h2&gt;

&lt;p&gt;A device limit without &lt;strong&gt;deactivation&lt;/strong&gt; is a trap that springs on honest customers. People replace computers constantly. If activations only ever accumulate, every loyal customer eventually hits the limit on hardware they no longer own.&lt;/p&gt;

&lt;p&gt;The answer is self-service deactivation: a &lt;a href="https://keylight.dev/features/customer-portal" rel="noopener noreferrer"&gt;customer portal&lt;/a&gt; listing the customer's activated devices, each with a remove button. Removing a device frees a slot instantly. The customer rearranges their own machines without a support ticket, and the limit stays meaningful because it is never the thing blocking a legitimate move.&lt;/p&gt;

&lt;p&gt;In the app, hitting the limit should surface as a clear, actionable message — not a dead end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;licensing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;enteredKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;licensing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// activationError explains the limit was reached;&lt;/span&gt;
    &lt;span class="c1"&gt;// point the customer at the portal to free a device.&lt;/span&gt;
    &lt;span class="nf"&gt;showActivationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;licensing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The balance
&lt;/h2&gt;

&lt;p&gt;Preventing license key sharing is a balance, not a war. A generous device limit plus painless self-service deactivation caps sharing at a reasonable number while being effectively invisible to every honest customer. A stingy one-device limit with hard blocks and no deactivation barely inconveniences a determined sharer and reliably angers the people paying you.&lt;/p&gt;

&lt;p&gt;Aim for the first. If you have an anti-sharing edge case worth a deeper post, &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I stop customers sharing license keys?
&lt;/h3&gt;

&lt;p&gt;Bind each key to a limited number of devices with an activation limit. The licensing system records each device and refuses activations past the limit, so one key cannot supply an unlimited number of installs.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I avoid punishing legitimate customers with anti-sharing measures?
&lt;/h3&gt;

&lt;p&gt;Set a generous device limit, make deactivation self-service so customers can move between machines, and degrade gracefully rather than hard-blocking on an ambiguous signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can license key sharing ever be fully prevented?
&lt;/h3&gt;

&lt;p&gt;No. One person with one device can always run one copy. The realistic goal is to make a key behave like a personal license, not a site license — which activation limits achieve.&lt;/p&gt;

</description>
      <category>desktop</category>
      <category>security</category>
      <category>indiedev</category>
      <category>licensing</category>
    </item>
    <item>
      <title>What Is Inside a Keylight Lease: The Ed25519 Format Explained</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:26:23 +0000</pubDate>
      <link>https://dev.to/nicodemanez/what-is-inside-a-keylight-lease-the-ed25519-format-explained-fg6</link>
      <guid>https://dev.to/nicodemanez/what-is-inside-a-keylight-lease-the-ed25519-format-explained-fg6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/ed25519-lease-format-explained" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most license systems answer "is this key valid?" by asking a server. Keylight answers it with math, locally, in milliseconds — because the license itself is a signed document your app can verify without a network call. Understanding what is inside that document, and why the signature protects it, is the fastest way to understand how Keylight's security model holds together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why signed leases beat opaque license strings + database lookups
&lt;/h2&gt;

&lt;p&gt;The classic licensing approach is a lookup: the customer enters a key, your server checks it against a database, and the server says yes or no. An opaque license string — just a token or a checksum-passing sequence — means nothing by itself. The string carries no self-describing information; the app has to phone home to learn what it is allowed to do.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;signed lease&lt;/strong&gt; flips the model. The lease is a self-describing document: it encodes who the customer is, which product they purchased, which features they have, and when the license expires. The &lt;strong&gt;Ed25519 signature&lt;/strong&gt; over the document is proof that Keylight's server minted it for exactly those values. Your app ships with the public key baked into the binary and verifies the signature locally — no server call, no round-trip, no database to query or spoof.&lt;/p&gt;

&lt;p&gt;The customer holds the document, but they cannot change what it says. The validity no longer lives in whether a server recognizes a token; it lives in a signature only Keylight's private key can produce. This is why the lease works offline, survives network outages, and still enforces your commercial limits correctly. For a broader introduction to why this model replaced opaque license strings, see &lt;a href="https://keylight.dev/blog/how-license-keys-work" rel="noopener noreferrer"&gt;how license keys work for desktop apps&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lease payload, field by field
&lt;/h2&gt;

&lt;p&gt;The canonical lease the SDK exposes after verification looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lk_01hx9z4bqncktjvx6a2r3p8wy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cus_Qk3mN9vTpLx2Zr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"productId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prod_macos_pro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"activationLimit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"activationCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"features"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"pro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"export"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-15T09:12:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expiresAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"revoked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MEUCIQDkP3...base64url...=="&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every field carries specific meaning that the SDK reads and enforces:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;id&lt;/code&gt;&lt;/strong&gt; — a unique, immutable lease identifier. This is what Keylight uses to look up the lease server-side during revalidation. It never changes after issuance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;customerId&lt;/code&gt;&lt;/strong&gt; — the Keylight customer record this lease was issued to, set at mint time from the Stripe payment. Ties the lease to a specific purchase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;productId&lt;/code&gt;&lt;/strong&gt; — which product and tier this lease covers. If you have multiple products or multiple plans, the SDK reads this to know which entitlement bucket applies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;activationLimit&lt;/code&gt;&lt;/strong&gt; — the maximum number of concurrent device activations. A standard license might allow 3; an enterprise tier might allow unlimited (represented as a very high number). This value is set at issuance based on the product tier purchased.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;activationCount&lt;/code&gt;&lt;/strong&gt; — the current number of active devices as of the last server sync. Keylight tracks this server-side and updates it on each activation and deactivation. This is a read-only tracking field exposed to your app via the lease object; it is &lt;strong&gt;not&lt;/strong&gt; checked by &lt;code&gt;LeaseVerifier.verify&lt;/code&gt;. The activation cap is enforced server-side when a device calls &lt;code&gt;/activate&lt;/code&gt; — the verifier only checks signature, key ID, and expiry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;features&lt;/code&gt;&lt;/strong&gt; — an array of &lt;strong&gt;entitlement strings&lt;/strong&gt; the app reads to decide which capabilities to unlock. Your app checks whether &lt;code&gt;"pro"&lt;/code&gt; or &lt;code&gt;"export"&lt;/code&gt; appears here before enabling those features. This is the mechanism that lets you sell different tiers from a single binary. The strings are stored sorted so that the client and server always reconstruct an identical payload regardless of insertion order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;issuedAt&lt;/code&gt;&lt;/strong&gt; — the ISO timestamp when the lease was minted. Used internally for clock-drift calculations and audit logging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;expiresAt&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;null&lt;/code&gt; for a lifetime license; an ISO timestamp for subscription or time-limited leases. The SDK rejects a lease that has expired beyond its skew tolerance window, even if the signature is valid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;revoked&lt;/code&gt;&lt;/strong&gt; — a boolean flag set to &lt;code&gt;true&lt;/code&gt; when a refund or chargeback is processed. On the next revalidation, the SDK picks this up and transitions the app to an invalid state. Because verification is offline-first, revocation is not instantaneous — a customer with no network access keeps using their cached lease until the app revalidates. This is the honest tradeoff: instant revocation would require a server call on every launch, which breaks offline usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sig&lt;/code&gt;&lt;/strong&gt; — the &lt;strong&gt;Ed25519 signature&lt;/strong&gt; over a compact wire payload, not over the JSON above. The JSON is the lease object as the SDK exposes it to your app. Internally, signing operates on a more compact wire payload — a pipe-delimited string with eight positional fields: &lt;code&gt;v3|kid|licenseKeyHash|instanceId|issuedAt|expiresAt|status|entitlements&lt;/code&gt;. This is what the Ed25519 signature actually covers; the SDK reconstructs and verifies that string before exposing the structured lease to your code.&lt;/p&gt;

&lt;p&gt;The fields covered by the signature are &lt;code&gt;kid&lt;/code&gt;, &lt;code&gt;licenseKeyHash&lt;/code&gt;, &lt;code&gt;instanceId&lt;/code&gt;, &lt;code&gt;issuedAt&lt;/code&gt; (integer seconds), &lt;code&gt;expiresAt&lt;/code&gt; (integer seconds), &lt;code&gt;status&lt;/code&gt;, and &lt;code&gt;entitlements&lt;/code&gt; (sorted, comma-joined). Modifying any of them produces a signature mismatch and the lease is rejected immediately. Fields like &lt;code&gt;customerId&lt;/code&gt;, &lt;code&gt;productId&lt;/code&gt;, &lt;code&gt;activationLimit&lt;/code&gt;, and &lt;code&gt;activationCount&lt;/code&gt; are part of the API/customer-facing model but are not positional fields in the signed wire payload.&lt;/p&gt;

&lt;p&gt;For a full treatment of how offline validation uses this structure, see &lt;a href="https://keylight.dev/blog/how-offline-license-validation-works" rel="noopener noreferrer"&gt;how offline license validation works&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Ed25519 signing protects every field
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ed25519&lt;/strong&gt; is a public-key signature algorithm. The important property for licensing is this: given only the public key (which your app ships with), it is computationally infeasible to produce a valid signature. The public key can verify signatures but cannot generate them.&lt;/p&gt;

&lt;p&gt;Keylight's servers hold the &lt;strong&gt;private key&lt;/strong&gt; — it never leaves Keylight's infrastructure. Your app ships with the matching &lt;strong&gt;public key&lt;/strong&gt; baked into the binary. An attacker who extracts the public key from your binary still cannot mint a valid lease: they would need the private key to produce a signature the public key accepts, and the math does not help them get there. Embedding the public key in the app is safe by design.&lt;/p&gt;

&lt;p&gt;Why Ed25519 over RSA? Three practical reasons. Ed25519 signatures are about 88 bytes; RSA-2048 signatures are around 344 bytes. Ed25519 verification is faster — relevant when your app verifies the lease on every launch. And Ed25519 has no parameter choices that can be gotten subtly wrong the way RSA key generation can. For desktop licensing where verification happens repeatedly and binary size matters, these add up.&lt;/p&gt;

&lt;p&gt;What tampering does in practice: the SDK reconstructs the v3 pipe-delimited string from the lease's &lt;code&gt;kid&lt;/code&gt;, &lt;code&gt;licenseKeyHash&lt;/code&gt;, &lt;code&gt;instanceId&lt;/code&gt;, &lt;code&gt;issuedAt&lt;/code&gt;, &lt;code&gt;expiresAt&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, and sorted-joined &lt;code&gt;entitlements&lt;/code&gt; fields, then calls &lt;code&gt;isValidSignature&lt;/code&gt; against the decoded &lt;code&gt;sig&lt;/code&gt;. If any of those eight fields was changed after signing — even one character — the reconstructed string differs from the one that was signed, the check fails, and the SDK rejects the lease. There is no partial match, no tolerance for minor edits. The lease is either intact or it is rejected.&lt;/p&gt;

&lt;p&gt;On the wire, Keylight uses the v3 pipe-delimited format. The SDK parses that format, derives the canonical payload string, and verifies the signature before populating the structured lease object. Your code never sees the raw wire format; it only sees a verified, structured lease — or a verification failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the SDK does on verification, and what it deliberately does not do
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LeaseVerifier.verify&lt;/code&gt; is the function the SDK calls on every lease read. It is explicit about its scope. Here is what it checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Signature validity&lt;/strong&gt; — reconstructs the v3 pipe-delimited payload and verifies the Ed25519 signature against the trusted public key for this tenant. A lease with a missing or empty signature is rejected immediately, before any other check.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Key ID (&lt;code&gt;kid&lt;/code&gt;) membership&lt;/strong&gt; — the lease carries a &lt;code&gt;kid&lt;/code&gt; identifying which signing keypair was used. &lt;code&gt;LeaseVerifier.verify&lt;/code&gt; looks up &lt;code&gt;kid&lt;/code&gt; in the tenant's trusted keyset (a dictionary keyed by &lt;code&gt;kid&lt;/code&gt;) and rejects the lease if it is absent — even if the signature itself is mathematically valid against some other key. This is how a compromised signing key gets invalidated: ship an app update that drops the compromised &lt;code&gt;kid&lt;/code&gt; from the trusted keyset.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Expiry&lt;/strong&gt; — checks &lt;code&gt;expiresAt&lt;/code&gt; against the current time with a &lt;code&gt;skewTolerance&lt;/code&gt; window (default 300 s / 5 minutes) to absorb minor clock drift. A lease past its expiry window is rejected. Callers do not need to check expiry separately; the verifier handles it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is how the SDK calls this at launch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Inside the SDK on app launch:&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;isValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;LeaseVerifier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cachedLease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;trustedKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trustedPublicKeys&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;trustedKeys&lt;/code&gt; argument is your configuration's &lt;code&gt;trustedPublicKeys&lt;/code&gt; — the dictionary of &lt;code&gt;kid → Curve25519.Signing.PublicKey&lt;/code&gt; values from your &lt;code&gt;KeylightConfiguration&lt;/code&gt;. The function returns a plain &lt;code&gt;Bool&lt;/code&gt; — it does not throw.&lt;/p&gt;

&lt;p&gt;What &lt;code&gt;LeaseVerifier.verify&lt;/code&gt; explicitly does &lt;em&gt;not&lt;/em&gt; do is equally important to understand:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not enforce activation count.&lt;/strong&gt; The &lt;code&gt;activationCount&lt;/code&gt; field is exposed in the lease object so your app can display it, but &lt;code&gt;LeaseVerifier.verify&lt;/code&gt; does not compare it against &lt;code&gt;activationLimit&lt;/code&gt;. Activation enforcement happens server-side when a device calls &lt;code&gt;/activate&lt;/code&gt;; the verifier only checks signature, &lt;code&gt;kid&lt;/code&gt;, and expiry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not protect your binary against reverse engineering.&lt;/strong&gt; A developer with a disassembler can find &lt;code&gt;LicenseManager.isEntitled&lt;/code&gt; and patch the branch. The SDK source is public. The signed lease stops someone from forging entitlements by editing a file; it does not stop someone from patching the binary that reads it. Keylight is a commercial licensing system, not DRM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not prevent use of a valid cached lease before revocation propagates.&lt;/strong&gt; If a customer's lease was valid at last sync and the customer has no network access, the lease remains valid locally until the app revalidates online. This is the fail-open tradeoff of offline-first verification — and it is deliberate. The alternative (fail-closed, requiring a server call on every launch) locks out legitimate customers with no internet access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not protect against a compromised private key.&lt;/strong&gt; If Keylight's private key were compromised, an attacker could mint arbitrary valid leases. The trust anchor is the private key staying on Keylight's servers. Keylight's threat model is explicit: Keylight stops casual abuse and enforces commercial limits. It is not the last line of defense against a skilled attacker, and it does not claim to be.&lt;/p&gt;

&lt;p&gt;The signed lease is a commercial licensing mechanism, not DRM. It raises the economic floor for abuse — editing the lease file does not work, sharing one key across more devices than the activation limit allows gets caught on the next revalidation — but it is not tamper-resistant in the DRM sense. For the full picture of the entitlement model and how the SDK integrates it, see the &lt;a href="https://keylight.dev/features/license-keys" rel="noopener noreferrer"&gt;license keys feature page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The field-by-field anatomy is the part most license-key guides skip, and it is where the security of the model actually lives. If there are aspects of the signing or verification flow you want to dig into further, &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt; and we'll extend this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is an Ed25519 lease?
&lt;/h3&gt;

&lt;p&gt;It is a small JSON document — covering the customer, product, expiry, activation limit, and features — signed by an Ed25519 private key held on Keylight servers. The app verifies the signature locally using the matching public key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can the customer modify their own lease?
&lt;/h3&gt;

&lt;p&gt;They can edit the file, but the signature will no longer match. The Keylight SDK rejects any lease whose signature does not verify against the embedded public key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Ed25519 and not RSA?
&lt;/h3&gt;

&lt;p&gt;Ed25519 produces smaller signatures (~88 bytes vs ~344 for RSA-2048), is faster to verify, and avoids the parameter-choice footguns RSA introduces. For desktop licensing where the app verifies the signature on every launch, the size and speed matter.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>security</category>
      <category>licensing</category>
      <category>desktop</category>
    </item>
    <item>
      <title>How License Keys Work for Desktop Apps</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:26:18 +0000</pubDate>
      <link>https://dev.to/nicodemanez/how-license-keys-work-for-desktop-apps-2kk7</link>
      <guid>https://dev.to/nicodemanez/how-license-keys-work-for-desktop-apps-2kk7</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://keylight.dev/blog/how-license-keys-work" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The "XXXXX-XXXXX-XXXXX" string you paste into a registration window looks simple, and the idea behind it is simple. But what makes a license key actually &lt;em&gt;secure&lt;/em&gt; has changed completely over the last two decades. This post explains how license keys work for desktop apps today, and why the old way no longer holds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old way: checksum keys
&lt;/h2&gt;

&lt;p&gt;For a long time, a license key was a string that was valid if its characters satisfied some arithmetic rule. The app contained the rule. When a customer typed in a key, the app ran the check locally: do these digits sum to the right value, do these segments relate in the expected way? If yes, unlock.&lt;/p&gt;

&lt;p&gt;This was cheap and worked offline, but it had a fatal flaw. The validity lived in a &lt;em&gt;pattern&lt;/em&gt;, and patterns can be reverse-engineered. Once one person worked out the rule — or simply found one key that passed — that key, or a generator for it, ended up on the internet. The app had no way to tell a paying customer's key from a pirate's, because both satisfied the same rule.&lt;/p&gt;

&lt;p&gt;Checksum keys are not a security mechanism anymore. They are a speed bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  The modern way: signed keys
&lt;/h2&gt;

&lt;p&gt;A modern license key does not encode validity in a pattern. It carries a &lt;strong&gt;cryptographic signature&lt;/strong&gt; — proof that a server you control minted this specific key for this specific entitlement.&lt;/p&gt;

&lt;p&gt;It works with a key pair. The server holds a secret &lt;strong&gt;private key&lt;/strong&gt; and uses it to sign. The app ships with the matching &lt;strong&gt;public key&lt;/strong&gt; and uses it to verify. The mathematics of the algorithm — Ed25519 is the standard choice — guarantees two things: only the holder of the private key can produce a signature the public key accepts, and the public key reveals nothing that helps forge one.&lt;/p&gt;

&lt;p&gt;So you can embed the public key in every copy of your app, ship it to the whole world, and an attacker still cannot mint a working key. The validity no longer lives in a guessable pattern; it lives in a signature only your server can produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually inside the key
&lt;/h2&gt;

&lt;p&gt;A signed key is not just a random-looking string — it is a small structured document, an &lt;strong&gt;entitlement&lt;/strong&gt;, with a signature attached. It typically carries who the license belongs to, which product and plan it covers, when it was issued, when it expires (if ever), and how many devices it may activate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lk_01hx9z4bqncktjvx6a2r3p8wy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"productId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prod_myapp_pro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"activationLimit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-15T09:12:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expiresAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"revoked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"base64url(ed25519_signature)"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature covers every field above it. If anyone edits a single character — bumping &lt;code&gt;activationLimit&lt;/code&gt; from 3 to 99, or flipping &lt;code&gt;revoked&lt;/code&gt; to false after a refund — the signature no longer matches, and the app rejects the key. The customer holds the document, but they cannot change what it says.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the app checks it
&lt;/h2&gt;

&lt;p&gt;When your app has a key, validation is a local computation: parse the document, run the signature check with the embedded public key, then read the plain fields — is it expired, is it revoked, is it within its device limit. No server call. The app can do this on a plane.&lt;/p&gt;

&lt;p&gt;That is the headline benefit of signed keys for &lt;em&gt;desktop&lt;/em&gt; apps specifically. A web app can validate against its server on every request for free, because it is always online. A desktop app is not always online, and a license check that needs the network will fail for legitimate customers. Signed keys remove that dependency. For the full treatment of local verification, see &lt;a href="https://keylight.dev/software-license-keys" rel="noopener noreferrer"&gt;software license keys explained&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Revocation is the one thing local verification cannot do alone — a key the app has never been told about cannot know it was refunded. So signed-key systems add a &lt;em&gt;periodic&lt;/em&gt; online re-check: every few days, when the app has a connection, it reconfirms the key server-side and picks up any revocation. Everyday validation stays offline; only the occasional re-check needs the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you are building it
&lt;/h2&gt;

&lt;p&gt;If you are adding licensing to a desktop app, the takeaway is: do not ship checksum keys. They will not protect your revenue. You want signed keys — which means a private key to manage, a signing service, an entitlement format, verification code in your app, and the periodic re-check logic.&lt;/p&gt;

&lt;p&gt;That is a real amount of infrastructure, which is why licensing layers exist to provide it. However you build it, the model is the same: a signed document the customer cannot forge or edit, verified locally by your app. If there is an angle on license keys you would like covered in more depth, &lt;a href="https://mappo.io/p/keylight-f30f8a" rel="noopener noreferrer"&gt;send us your feedback&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a software license key?
&lt;/h3&gt;

&lt;p&gt;A license key is a unique value a customer receives after paying, which the app checks to unlock paid functionality. Modern keys are cryptographically signed so the app can verify them without contacting a server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are old-style checksum license keys still secure?
&lt;/h3&gt;

&lt;p&gt;No. A checksum key is valid if its characters satisfy a pattern, so one leaked working key can be shared endlessly. Signed keys solve this — only the server holding the private key can mint a valid one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a license key work without an internet connection?
&lt;/h3&gt;

&lt;p&gt;Yes, if it is signed. The app ships with a public key and verifies the signature locally, so no network call is needed to confirm the license is genuine.&lt;/p&gt;

</description>
      <category>desktop</category>
      <category>licensing</category>
      <category>programming</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>How to ship and sell a paid desktop app outside the app stores (2026)</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 06:38:08 +0000</pubDate>
      <link>https://dev.to/nicodemanez/how-to-ship-and-sell-a-paid-desktop-app-outside-the-app-stores-2026-42j4</link>
      <guid>https://dev.to/nicodemanez/how-to-ship-and-sell-a-paid-desktop-app-outside-the-app-stores-2026-42j4</guid>
      <description>&lt;p&gt;You built a desktop app — macOS, Windows, Linux, native or Tauri/Electron — and you want to sell it directly instead of handing 15–30% to Apple or Microsoft. Selling outside the stores means you keep the margin and own the customer relationship. It also means the plumbing the stores quietly handled is now yours: distribution, payments, licensing, updates, support.&lt;/p&gt;

&lt;p&gt;Here's the whole path, in roughly the order you'll hit it — with the licensing part (the one most people underestimate) covered properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sell outside the app stores
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Margin.&lt;/strong&gt; You keep 85–100% instead of giving up the store's cut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control.&lt;/strong&gt; Your own pricing, trials, upgrades, and refund policy — no review gatekeeping, no waiting on approval to ship a fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The relationship.&lt;/strong&gt; You get the customer's email and can actually support and re-sell to them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is that the things the store did invisibly — vouching for your binary, taking payment, enforcing the purchase — are now your job. This isn't a Mac thing. Windows devs sell direct constantly, Linux too, and a Tauri or Electron app ships to all three from one codebase. The work below applies across the board.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Distribution and updates
&lt;/h2&gt;

&lt;p&gt;Before anyone pays, they have to trust and install the thing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;macOS:&lt;/strong&gt; sign with a Developer ID certificate and &lt;strong&gt;notarize&lt;/strong&gt; with Apple, or Gatekeeper will scare users off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; an &lt;strong&gt;Authenticode&lt;/strong&gt; code-signing certificate, ideally EV to build SmartScreen reputation faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; package as AppImage, &lt;code&gt;.deb&lt;/code&gt;/&lt;code&gt;.rpm&lt;/code&gt;, or Flatpak depending on your audience.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then updates, because the store won't push them for you: &lt;strong&gt;Sparkle&lt;/strong&gt; (macOS), &lt;strong&gt;Squirrel/electron-updater&lt;/strong&gt; (Electron), the &lt;strong&gt;Tauri updater&lt;/strong&gt;, or your own endpoint. Decide this early — retrofitting auto-update onto a shipped app is miserable.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Getting paid
&lt;/h2&gt;

&lt;p&gt;Two real models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe (you're the merchant).&lt;/strong&gt; Lower fees, full control, your brand on the receipt. The catch: sales tax and EU VAT are &lt;em&gt;your&lt;/em&gt; responsibility (handle it yourself or bolt on a tax service).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merchant of Record (Lemon Squeezy, Paddle, Polar).&lt;/strong&gt; They become the seller of record and handle worldwide tax for you, for a higher cut. Less control, far less compliance headache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule of thumb: pick an MoR if tax/compliance is the thing you least want to think about; pick Stripe if you want margin and control and will deal with tax separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Licensing and activation — the part people underestimate
&lt;/h2&gt;

&lt;p&gt;Here's the trap: &lt;strong&gt;a payment is not protection.&lt;/strong&gt; "They paid" and "this specific install is allowed to run" are two different facts, and the gap between them is where piracy and support tickets live. Licensing is the layer that closes it, and on the desktop it has to keep working with no network.&lt;/p&gt;

&lt;p&gt;What a real licensing layer actually has to do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Issue&lt;/strong&gt; a key when someone buys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activate&lt;/strong&gt; that key on a device (bind it, so one key isn't infinite installs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; it on every launch — &lt;em&gt;including offline&lt;/em&gt;, on a plane or behind a firewall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce&lt;/strong&gt; device limits and &lt;strong&gt;gate&lt;/strong&gt; features or tiers (entitlements).&lt;/li&gt;
&lt;li&gt;Handle &lt;strong&gt;trials, expiry, refunds, and revocation&lt;/strong&gt; without locking out honest customers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The cross-platform tax
&lt;/h3&gt;

&lt;p&gt;If you ship macOS &lt;em&gt;and&lt;/em&gt; Windows &lt;em&gt;and&lt;/em&gt; Linux — or native &lt;em&gt;and&lt;/em&gt; Tauri &lt;em&gt;and&lt;/em&gt; Electron — you need the &lt;strong&gt;same licensing logic everywhere&lt;/strong&gt;. Re-implementing key validation per platform is exactly where subtle bugs and piracy creep in: a weaker check on one OS undermines all of them. One consistent verification path across every build is the goal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build vs. buy
&lt;/h3&gt;

&lt;p&gt;Rolling your own means a signing server, key management, device fingerprinting, and an offline-verification scheme done &lt;em&gt;correctly&lt;/em&gt; — which means &lt;strong&gt;never trusting a plain &lt;code&gt;{"valid": true}&lt;/code&gt; from your server&lt;/strong&gt; (a proxy forges that for free). The durable approach is a cryptographically &lt;strong&gt;signed lease&lt;/strong&gt;: your server signs the license state with a private key, your app verifies it locally with the public key, no network required. (&lt;a href="https://keylight.dev/offline-license-validation" rel="noopener noreferrer"&gt;Here's how that works in detail.&lt;/a&gt;) Most indie devs underestimate this by a week or more.&lt;/p&gt;

&lt;p&gt;The done-for-you option is what &lt;a href="https://keylight.dev" rel="noopener noreferrer"&gt;Keylight&lt;/a&gt; exists for: one control plane with finished SDKs for &lt;strong&gt;Swift, Rust, JavaScript/TypeScript, C#, and C++&lt;/strong&gt;, so the same licensing behaves identically across native macOS/Windows/Linux, Tauri, and Electron. Offline Ed25519-signed leases verify on-device, entitlements ride inside the lease, and connecting Stripe mints keys on payment — no webhook code. Disclosure: I build it.&lt;/p&gt;

&lt;p&gt;For the broader landscape — Keygen, Cryptolens, LicenseSpring, and the keys-as-a-feature options like Lemon Squeezy and Polar — there's an honest &lt;a href="https://keylight.dev/desktop-app-licensing" rel="noopener noreferrer"&gt;tool-by-tool comparison here&lt;/a&gt;; they're good at different things and the right pick depends on whether your bottleneck is control, payments, or time.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Piracy — the realistic stance
&lt;/h2&gt;

&lt;p&gt;You will not stop a determined cracker, and chasing that goal usually means punishing paying customers. The achievable goal: make casual copying inconvenient, keep honest people honest, and &lt;strong&gt;never ship online-only checks that lock out legitimate users&lt;/strong&gt; when their network drops. Offline-friendly licensing beats aggressive DRM on every axis that matters for an indie business.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Refunds, support, and self-service
&lt;/h2&gt;

&lt;p&gt;The tickets you'll actually get: "I got a new laptop and can't activate," "I need to move my license," "I bought the wrong tier." A &lt;strong&gt;self-service portal&lt;/strong&gt; — deactivate a device, move a seat, see your license — saves you from being a human license desk. Build a thin one or use a provider that includes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order to do it in
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Sign + notarize/package so people can install it.&lt;/li&gt;
&lt;li&gt;Wire payments (Stripe or an MoR).&lt;/li&gt;
&lt;li&gt;Add licensing + activation — keys, device limits, offline verification, entitlements.&lt;/li&gt;
&lt;li&gt;Add auto-update.&lt;/li&gt;
&lt;li&gt;Add a trial and a self-service portal.&lt;/li&gt;
&lt;li&gt;Ship it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The app was the hard part, and you already did it. The rest is plumbing — and licensing is the plumbing people get wrong, because &lt;em&gt;paid&lt;/em&gt; and &lt;em&gt;allowed to run offline on the right number of devices&lt;/em&gt; are different problems. Get distribution and payments handled, then make licensing boring: signed leases, device limits, entitlements, one SDK per stack. Then go sell it.&lt;/p&gt;

&lt;p&gt;If you want the licensing layer handled across every desktop stack in one place, that's &lt;a href="https://keylight.dev" rel="noopener noreferrer"&gt;Keylight&lt;/a&gt; — there's a free tier to start.&lt;/p&gt;

</description>
      <category>desktop</category>
      <category>indiedev</category>
      <category>saas</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to add license keys to a SwiftUI macOS app (in under an hour)</title>
      <dc:creator>Nico</dc:creator>
      <pubDate>Sat, 27 Jun 2026 06:38:05 +0000</pubDate>
      <link>https://dev.to/nicodemanez/how-to-add-license-keys-to-a-swiftui-macos-app-in-under-an-hour-ge</link>
      <guid>https://dev.to/nicodemanez/how-to-add-license-keys-to-a-swiftui-macos-app-in-under-an-hour-ge</guid>
      <description>&lt;p&gt;You built a Mac app, you want to sell it outside the App Store, and now you need licensing: a key the customer enters, an activation that sticks, and feature gates that hold up offline. Here's how to do it in an afternoon without standing up a backend.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: this is cross-posted from the &lt;a href="https://keylight.dev/blog/add-license-keys-swift-macos-app" rel="noopener noreferrer"&gt;Keylight blog&lt;/a&gt;. I build Keylight, so this uses it as the worked example — the &lt;em&gt;shape&lt;/em&gt; of the solution applies whatever SDK you choose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The three things licensing actually has to do
&lt;/h2&gt;

&lt;p&gt;Strip away the marketing and every licensing system does exactly three jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Activate&lt;/strong&gt; — turn a key the user pastes in into proof-of-purchase bound to this device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; — on every launch, confirm that proof is still valid, &lt;em&gt;including offline&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate&lt;/strong&gt; — unlock features based on the tier/entitlements the license carries.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you build this by hand you're writing a server, a crypto layer, and a state machine. The point of an SDK is to skip all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Add the SDK
&lt;/h2&gt;

&lt;p&gt;Add the Swift package in Xcode (File ▸ Add Package Dependencies) pointing at the Keylight Swift SDK, then configure it once with your tenant key at app launch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Keylight&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;keylight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Keylight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"your_tenant_key"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Activate a key
&lt;/h2&gt;

&lt;p&gt;Give the user a text field and call &lt;code&gt;activate&lt;/code&gt;. This is the one online step — it exchanges the key for a signed, device-bound lease that's stored locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;keylight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;enteredKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// lease stored — the app is now licensed on this device&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// show the user why: invalid key, device limit reached, etc.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Verify on launch (offline-safe)
&lt;/h2&gt;

&lt;p&gt;On every subsequent launch you don't hit the network. The SDK verifies the stored lease's Ed25519 signature locally and hands you a state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;keylight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkOnLaunch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;licensed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;unlockApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lease&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;daysLeft&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;runTrial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;daysLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;daysLeft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;invalid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;showActivationScreen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No server call, so the app opens instantly and works on a plane. The lease carries a max-offline window; past it the SDK refreshes online once, which is also where a revoked or refunded license gets caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Gate features by entitlement
&lt;/h2&gt;

&lt;p&gt;Because entitlements are signed &lt;em&gt;inside&lt;/em&gt; the lease, feature gating is offline too. Don't scatter &lt;code&gt;if licensed&lt;/code&gt; across your views — read entitlements once and drive your UI off them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@Observable&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;Licensing&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isPro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;entitlements&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pro"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;licensing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPro&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;ProExportButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;UpgradePrompt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Get paid (the part people forget)
&lt;/h2&gt;

&lt;p&gt;A license is only useful if buying one mints it. If you connect Stripe, a completed payment can mint the license automatically — no webhook code to write — so the customer's key works the moment they pay. That closes the loop: pay → key → activate → offline-verified Pro.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you skipped by not building it yourself
&lt;/h2&gt;

&lt;p&gt;A signing server, Ed25519 key management, lease parsing, device binding, a trial/expiry state machine, and Stripe webhook plumbing. That's the week-plus you just didn't spend.&lt;/p&gt;

&lt;p&gt;Full docs and the free tier are at &lt;a href="https://keylight.dev" rel="noopener noreferrer"&gt;keylight.dev&lt;/a&gt;. If you're on Tauri or Electron instead of native Swift, the same SDK pattern exists in JS/Rust.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>macos</category>
      <category>swiftui</category>
      <category>indiedev</category>
    </item>
  </channel>
</rss>
