Skip to content
codebiy
← Back to the writing desk

Selling a Pro license with no accounts and no license server

An Arvlin Pro license is one Ed25519-signed line of text, issued without a database and checked offline. On launch day Polar's webhook deliveries were rejected with 403; the other delivery path does not use the webhook.

Arvlin is our Mac app for developers who work with AI tools: a library for prompts and skills, a view of local dev services and AI usage, and Mac cleanup where every removal is reviewed. The Free plan has no account and no time limit, and Pro is a one-time purchase. Buying Pro does not change that: no sign-in, no activation server, no customer database on our side.

The license is one line of text. The site that sells Pro computes it from the order and stores nothing, and the app verifies it offline. The app is written in Swift 6 for macOS 14 and later and verifies with CryptoKit. The site runs on Next.js 16, takes payment through Polar with @polar-sh/sdk and signs with Node's crypto module. On 15 September 2026, the day the site launched, one of the two delivery paths failed: our webhook rejected Polar's signatures, and no license email went out until a fix 28 minutes after the launch commit.

The license format: a payload and a signature

The license is two base64url strings joined by a dot: a JSON payload and an Ed25519 signature over it. The issuer is one function on the site that sells Pro:

/// Produces the token ProLicenseVerifier accepts. Ed25519 is deterministic, so
/// the same order always yields the same license: the thank-you page and the
/// webhook email agree without any stored state.
export function issueLicense(
  order: { id: string; createdAt: Date },
  key: KeyObject,
): string {
  if (!LICENSE_ID.test(order.id))
    throw new Error("Order id cannot be a license id.");
  if (Number.isNaN(order.createdAt.getTime()))
    throw new Error("Order date is invalid.");
  const payload = Buffer.from(
    JSON.stringify({
      issuedAt: order.createdAt.toISOString(),
      licenseID: order.id,
      product: "arvlin-pro",
      version: 1,
    }),
  );
  return `${payload.toString("base64url")}.${sign(null, payload, key)
    .toString("base64url")}`;
}

The payload has four fields and holds no customer name, no email address and no device. The app holds only the 32-byte public key and checks the signature with CryptoKit's Curve25519.Signing.PublicKey. The private key is not in the app. The site reads it per request, never at build time, so its Docker image builds without it.

Illustration: a blank paper ticket with a red wax seal lies under a magnifying loupe on a stand, and an unplugged network cable lies beside it. The license is one signed line of text, and the app verifies it offline.

The site keeps no record of licenses. The license id is Polar's order id and the issue time is the order's creation time. EdDSA signatures are deterministic (RFC 8032, section 8.2), so one order always produces one string, whenever and wherever it is computed.

That property replaces a table of issued licenses. There is nothing to look up and nothing to keep in sync. If a buyer loses the email, the license we send again is the same string, computed again from the order.

The id rule is the same on both sides: letters, digits and ._:-, at most 128 characters. The site refuses to sign an id the app would refuse to accept.

Delivery by page and by email

A license reaches the buyer twice, by paths that share no state. After checkout, Polar sends the browser to a page on our site. The page asks Polar for the paid order behind that checkout and signs the license on the spot; if the order is not marked paid yet, it says so and offers a retry. Separately, Polar calls a webhook when the order is paid, and the webhook emails the same license.

The webhook has one deliberate property. If sending the email throws, the request fails with a 500, and Polar delivers the event again: it retries a failed delivery up to ten times with exponential backoff.

The launch-day failure: Polar webhook signatures rejected with 403

On 15 September 2026, the day the site launched, the email path did not work. We verified deliveries with validateEvent from @polar-sh/sdk 0.49. That function uses the UTF-8 bytes of the whole secret string as the HMAC key, which is Polar's older scheme. For secrets generated on or after 8 September 2026, Polar signs the way Standard Webhooks defines, with the secret passed as it is. Polar's delivery guide states both rules, and our secret was a new one. Every delivery failed verification, our route answered 403, and no license email went out.

The fix landed 28 minutes after the launch commit. It verifies a delivery with the standardwebhooks library against both keys, and its test signs a delivery each way and rejects a wrong secret and a tampered body. We kept no note of how the rejected deliveries were first noticed. According to the same guide, Polar's own SDKs try both keys from 1.0.0-alpha.19 on.

The page does not go through the webhook, so a rejected delivery does not touch it. That is the case for two paths: the same string from two places that fail for different reasons.

The verifier in the app

The app verifies the signature first and then distrusts the payload anyway:

// A closed schema avoids silently interpreting misspelled expiry fields
// as a lifetime license.
guard let object = try? JSONSerialization.jsonObject(with: payload)
        as? [String: Any],
      Set(object.keys).isSubset(of: [
          "version", "product", "licenseID", "issuedAt", "expiresAt"
      ]) else {
    throw ProLicenseError.invalidPayload
}

The format allows an optional expiry, although the licenses we sell carry none. That is why an unknown key has to be an error and cannot be skipped.

Dates get the same treatment. The verifier matches a complete RFC 3339 timestamp with its own pattern and round-trips the result through the calendar. The system's ISO8601DateFormatter is more lenient than a license check should be: on macOS 27 it read 30 February as 2 March, and it accepted a valid timestamp followed by other text.

The verifier also bounds size and time. The text is at most 64 KiB, the signature exactly 64 bytes, the public key exactly 32. The base64url must be canonical and unpadded. The issue time may be at most five minutes ahead of the Mac's clock, and the error for a license from the future tells the user to check the Mac's date and time.

Strictness on one side is only safe if the other side cannot drift. The issuer is TypeScript on the site and the verifier is Swift in the app, so the same test token, made with a test-only key, is pinned in both repositories. The site's test asserts that issuing for a fixed order produces exactly that token. The app's test asserts that exactly that token verifies and yields the expected id and time. A change to a field name, the date format or the encoding fails a test on the side that made it.

Activation and development builds

In the app, activation is pasting the text into the Pro screen. The app verifies it and saves it to a license file through a temporary file and a rename, readable by the owner only. A failed write does not unlock Pro and does not replace a license that was already there.

The file is verified again at launch and every 60 seconds while the app runs, so a file that is removed or damaged drops Pro without a restart. Removing the license from a Mac is one action in the app, and it leaves the library alone.

There is no debug switch that grants Pro. For development, a script generates its own key pair, issues a license with it and builds the app with that public key, so a development build reaches Pro by the path production uses.

What the license terms cover

The alternative to an offline check is a separate verification system, which means a server the app has to reach. We did not build one, on one condition: the sales and refund policy has to fit the check. Ours is written to fit. One license is for one person on up to two Macs, and the terms cover sharing and refunds.

What we get in return is an app that never has to reach a server to unlock.

What we would do again, and one thing we would change

  • Make the license a pure function of the order. With no stored state there is no state to lose.
  • Deliver it by two paths that fail for different reasons.
  • Pin one token in every codebase that touches the format.
  • Close the schema. With a license, the generous default is the dangerous one.
  • Develop against the production path, with a development key.
  • Write the sales and refund terms together with the check, so that they fit it.

The one thing we would change: before launch, we would send a delivery signed the way Polar signs in production through the webhook check.

KEEP READINGTikTok Direct Post audit: why public posts failed after app review ↗App Store Connect's salesReports endpoint said “no sales” too early ↗