Skip to main content

Core Concepts

Deep dive into how Paycheck works.

Offline-First Design

Paycheck is built around a simple principle: after activation, your app should work without network access.

The license is a signed JWT. Your app embeds the project's public key at build time and validates locally. No server round-trip needed.

┌─────────────────┐     ┌─────────────────┐
│ Your App │ │ Paycheck API │
│ │ │ │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │Public Key │ │ │ │Private Key│ │
│ └───────────┘ │ │ └───────────┘ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ Validates │◄────│ Signs JWT │
│ JWT locally │ │ │
└─────────────────┘ └─────────────────┘

JWT Expiration Model

Paycheck uses three distinct expiration concepts:

1. Token Expiration (exp)

Purpose: Controls how quickly revocation propagates.

When a license is revoked, active tokens remain valid until their exp claim. The SDK automatically refreshes tokens before expiration, picking up revocations on refresh.

  • Default: 1 hour
  • Trade-off: Shorter = faster revocation propagation, more refresh traffic

2. License Expiration (license_exp)

Purpose: When the customer's access ends.

For subscription products, this is set to the end of the billing period. For perpetual licenses, it's null.

  • Null: Perpetual access
  • Timestamp: Access ends at this time

3. Updates Expiration (updates_exp)

Purpose: Version entitlement for perpetual licenses.

Compare against your app's build timestamp to determine if this license covers the current version.

const BUILD_TIMESTAMP: i64 = 1704067200;

if license.updates_exp.map_or(true, |exp| BUILD_TIMESTAMP <= exp) {
// User can use this version
} else {
// Prompt to renew for updates
}
  • Null: Access to all versions forever
  • Timestamp: Access to versions released before this time

Device Identity

UUID (Web/Browser)

For web applications. Generated once, stored in localStorage.

  • Pros: Works everywhere, no special permissions
  • Cons: Lost if localStorage cleared, not tied to hardware

Machine ID (Desktop)

Hardware-derived identifier, stable across reinstalls.

  • Pros: Survives app reinstalls, tied to physical machine
  • Cons: Requires read access to system identifiers

The SDK handles this automatically:

// Desktop apps
client.activate(code, DeviceType::Machine)?;

// Web apps
client.activate(code, DeviceType::Uuid)?;

Email-Based Activation

Paycheck uses no permanent license keys. Instead:

  1. Customer purchases → receives activation code via email
  2. Code is short-lived (30 min TTL)
  3. Code format: PREFIX-XXXX-XXXX (40 bits entropy)
  4. Codes are single-use

Recovery Flow

When a customer needs to reactivate (new device, lost data):

  1. User provides purchase email
  2. System looks up license by email hash (SHA-256)
  3. New activation code sent to that email
  4. User activates with new code

No license keys to manage, lose, or share.

Device Limits

Checked server-side during activation, not stored in the JWT.

Activation Request


┌───────────────────┐
│ Check device count│
│ against limit │
└───────────────────┘

├── Under limit → Create device, issue JWT

└── At limit → Return error

Self-Deactivation

Users can free up device slots:

client.deactivate_current_device()?;
// Requires the JWT (proves device identity via JTI)

Remote Deactivation

Org admins can deactivate devices via the API (for lost device recovery).

Revocation

Token Revocation

Individual JWTs can be revoked by JTI. Tracked per-license.

License Revocation

Revoked licenses:

  • Cannot refresh tokens
  • Cannot activate new devices
  • Existing tokens remain valid until exp

Revocation Propagation

Since tokens are validated locally, revocation isn't instant:

  1. License revoked via admin API
  2. Existing tokens still valid until exp
  3. SDK attempts refresh → gets revoked error
  4. App shows "license revoked" state

For apps requiring faster revocation, use online validation:

// Check revocation status with server
if !client.validate_online()? {
show_license_revoked_ui();
}

Multi-Tenant Architecture

Operators (Paycheck platform admins)

└── Organizations (indie dev companies)

├── Members (owner, admin, member roles)

├── Payment Config (Stripe/LemonSqueezy keys)

└── Projects (each software product)

├── Products (pricing tiers)
│ │
│ └── Licenses → Devices

└── Ed25519 key pair

Isolation

  • Each project has its own signing key pair
  • Keys are encrypted at rest (envelope encryption)
  • Organizations configure their own payment providers
  • Audit logs are per-organization

Envelope Encryption

Sensitive data (private keys, payment credentials) is encrypted at rest:

Master Key (file)

▼ HKDF-SHA256
Per-Entity DEK (derived from entity ID)

▼ AES-256-GCM
Encrypted Data (stored in SQLite)
  • Master key: Loaded from file, strict permissions (0400)
  • DEK derivation: Uses entity ID as info parameter
  • Storage format: ENC1 || nonce || ciphertext

Rate Limiting

Protects against abuse at multiple tiers:

TierLimitEndpoints
Strict10 RPM/buy, /activation/request-code
Standard30 RPM/redeem, /validate, etc.
Relaxed60 RPM/health
Org Ops3000 RPM/orgs/*

Limits are per-IP. Exceeded limits return 429 Too Many Requests.

Philosophy

Paycheck is a payment flow with cryptographic receipts, not DRM.

Design principles:

  1. Serve the honest majority - The 95% who just want to pay and use your software
  2. Offline-first - Network should be optional after activation
  3. No security theater - Skip anti-tampering, obfuscation, or "protection" that only annoys legitimate users
  4. Privacy-respecting - Store email hashes, not emails. No tracking.
  5. Developer-friendly - Simple API, clear documentation, predictable behavior