Launchday
Documentation

Launchday Docs

Everything you need to ship and sell native apps: license management, release distribution, automatic update feeds, waitlists, file hosting, and a CLI to wire it all together.

What is Launchday?

Launchday is a backend platform for app developers. It handles the operational infrastructure every paid app needs, so you can focus on building, not plumbing.

License management

Generate, activate, and revoke license keys. Ed25519-signed payloads let your app verify licenses offline after the first check.

Release distribution

Upload builds for any platform. Signed download URLs served from your own S3-compatible storage, with a Sparkle appcast for macOS auto-updates.

Waitlist

Collect email signups before launch. Assign beta access from the dashboard and send license keys automatically when a spot opens up.

CLI

The launchday CLI turns shipping a release into a one-liner. Upload, list, yank, and run a health check from your terminal.

Core concepts

Product
The top-level entity: your app. A product can have multiple platforms (macOS, iOS, Windows, etc.), each with its own releases and namespace.
Platform & namespace
Each platform gets a namespace, a short slug like com.example.myapp. Namespaces are unique per platform, so you can reuse the same identifier across macOS and Windows. The namespace drives the download and appcast URLs.
Configurations
Email and storage configs are org-level resources you create once and attach to any product or platform. This lets you share a single Mailgun domain or S3 bucket across all your products.
Signed payloads
License validation responses are signed with a per-product Ed25519 key. Your app verifies the signature locally with no network call needed after the first activation.

Getting started

1. Create a product

In the dashboard, create a product and give it a name. Then add at least one platform (e.g. macOS) with a namespace. The namespace is a reverse-domain identifier like com.example.myapp.

2. Configure your API endpoint

By default, all API calls go directly to api.launchday.one — no DNS setup required. Pass your namespace and platform in the request body to identify your product. This is the standard integration path and requires no domain configuration.

Optional (Studio plan): add a CNAME to serve the license API from your own subdomain. When a custom domain is set, the namespace and platform fields are no longer required — the host header identifies your product automatically.

No backend needed? If you sell through Stripe or Paddle, Launchday's Payment Webhooks feature can generate and email a license to your customer automatically when a payment completes — no server-side code required on your end.

DNS (optional)
launchday.yourapp.com  CNAME  api.launchday.one   // license API (Studio plan)

If using a custom domain, set it on your platform in Products → your product → Platforms, then add the domain in your Fly dashboard or contact support to provision the TLS certificate.

3. Get your public key

Go to Products → your product → Settings → Public Key and copy the hex-encoded Ed25519 public key. Bundle it with your app. It never changes unless you rotate keys.

4. Attach configurations

Create an email config and a storage config under Settings, then link them to your product. Without an email config, license emails won't be sent. Without a storage config, release uploads will use the default Launchday storage.

License

Integration

Launchday uses Ed25519-signed payloads so your app can verify licenses completely offline after the first network check. The flow is:

  1. 1 On first launch, call POST /v1/license/validate with mode: "activate" to bind the device
  2. 2 Verify the returned signature against the payload using your bundled Ed25519 public key
  3. 3 Cache the payload + signature in the Keychain for offline use
  4. 4 On subsequent launches, use mode: "check". If offline, fall back to the cached payload (reject if expired)

Recommended launch flow

flow
App launch
    
    ├─ Load cached payload from Keychain
    │       │
    │       ├─ Valid & not expired → allow (offline ok)
    │       └─ Missing / expired → must validate online
    
    └─ POST /v1/license/validate (mode: "activate" first time, "check" after)
            
            ├─ valid: true  → verify signature → cache payload → allow
            └─ valid: false → show license entry UI
License API base URL: https://api.launchday.one (or your custom domain) Downloads & appcast: https://releases.launchday.one Content-Type: application/json
POST

Validation

Activate a license on a device for the first time, or check an already-activated license.

endpoint
POST /v1/license/validate

Request body

JSON
{
  "license_key": "MYAPP-XXXX-XXXX-XXXX",
  "device_id":  "unique-device-identifier",
  "mode":       "activate",
  "namespace":  "com.example.myapp",  // required by default; omit only with a Studio plan custom domain
  "platform":   "macos"               // required by default; omit only with a Studio plan custom domain
}
Field Type Required Description
license_key string The license key to validate
device_id string A stable unique ID for this device. Use IOPlatformUUID on macOS, identifierForVendor on iOS.
mode string "activate": binds device if not already bound. "check": verifies an already-bound device without creating a new binding.
namespace string ✓ default Your platform namespace (e.g. com.example.myapp). Required when calling api.launchday.one directly — this is the standard path and requires no DNS setup. Only omit if using a custom CNAME domain (Studio plan).
platform string ✓ default Platform identifier. One of macos, ios, windows, linux, android. Required alongside namespace when using the default api.launchday.one endpoint.
200 OK

Success response

JSON
{
  "valid":          true,
  "license_status": "active",
  "key_id":         "abc123",
  "signature":     "a1b2c3...hex",
  "payload": {
    "license_key":     "MYAPP-XXXX-XXXX-XXXX",
    "device_id":       "unique-device-identifier",
    "namespace":       "com.example.myapp",
    "license_status":  "active",
    "issued_at":       "2026-02-27T12:00:00Z",
    "expires_at":      "2027-02-27T12:00:00Z"
  }
}

Error responses

JSON
// 200 OK (invalid license) or 4xx
{ "error": "license_not_found" }
{ "error": "revoked" }
{ "error": "expired" }
{ "error": "max_devices_reached" }
{ "error": "device_not_bound" }
{ "error": "invalid_mode" }
POST

Deactivation

Remove a device binding. Call this when the user uninstalls or transfers their license.

endpoint
POST /v1/license/deactivate

Request body

JSON
{
  "license_key": "MYAPP-XXXX-XXXX-XXXX",
  "device_id":  "unique-device-identifier"
}
200 OK

Success response

JSON
{ "deactivated": true }
POST

Request by email

Creates a new license (or resends an existing one) and emails it to the user. Requires an email config linked to the product.

endpoint
POST /v1/license/request

Request body

JSON
{
  "email": "user@example.com"
}
200 OK

Success response

JSON
{
  "success": true,
  "message": "If an account exists, a license key has been sent."
}
The response is intentionally vague to prevent email enumeration. A license email is always sent if the address is valid.

Offline verification

Every successful validate response includes a payload and signature. Verify them locally using your bundled Ed25519 public key.

  1. 1 JSON-encode the payload object. Field order is alphabetical (Go's json.Marshal): device_id, expires_at, issued_at, license_key, license_status, namespace
  2. 2 Verify the hex-encoded signature against the encoded bytes using Ed25519
  3. 3 Check expires_at is in the future and license_status is "active"

Swift example

LicenseVerifier.swift
import CryptoKit

func verifyPayload(
  _ response: ValidationResponse,
  publicKeyHex: String
) -> Bool {
  guard
    let pubKeyData = Data(hexString: publicKeyHex),
    let pubKey = try? Curve25519.Signing
      .PublicKey(rawRepresentation: pubKeyData),
    let sigData = Data(hexString: response.signature),
    let payloadData = try? JSONEncoder()
      .encode(response.payload)  // must match Go field order
  else { return false }

  guard pubKey.isValidSignature(
    sigData, for: payloadData
  ) else { return false }

  let expires = response.payload.expiresAt
  return expires > Date() &&
    response.payload.licenseStatus == "active"
}

Waitlist

Collect email signups before your app is ready. The waitlist API is public. Embed a simple form on your landing page. From the dashboard you can view the queue, assign beta spots, and trigger automatic invite emails.

When you assign a spot, Launchday generates a license key and sends it via your configured email config. No code required on your end.

POST

Join waitlist

Public endpoint. No authentication required.

endpoint
POST /v1/waitlist/join

Request body

JSON
{
  "email":                "user@example.com",
  "product_id":          "uuid-of-product",
  "form_token":          "hmac-hex-string",
  "cf_turnstile_response": "turnstile-token"  // optional
}

No CNAME required — requests go directly to https://api.launchday.one/v1/waitlist/join. Set a Waitlist Domain on your platform to restrict submissions to requests originating from that domain.

200 OK

Success response

JSON
{ "success": true }

Payment Webhooks

Launchday can automatically generate and email a license to your customer when a payment completes — no backend required on your side. Connect your payment provider once, and Launchday handles the rest.

Supported providers and events:

  • Stripecheckout.session.completed
  • Paddle Billingtransaction.completed

Webhook endpoint

endpoint
POST https://api.launchday.one/v1/webhooks/{webhook_id}

The webhook_id is an opaque UUID generated by Launchday — not guessable, and rotatable at any time from the dashboard.

Webhooks without a configured signing secret will return HTTP 422 until the secret is added. This prevents accidental license generation from unverified requests.

Setup

  1. 1 In your product dashboard, go to Settings → Payments
  2. 2 Click Add webhook — choose your payment provider (Stripe or Paddle) and the scope (all platforms or a specific platform)
  3. 3 Copy the generated webhook URL from Launchday
  4. 4 Paste it as a webhook endpoint in your Stripe dashboard or Paddle dashboard
  5. 5 Copy the signing secret generated by Stripe or Paddle and paste it back into Launchday on the same webhook settings screen

Scope behaviour

Scope Result
Product-scoped One license per platform is issued. If your product has macOS and iOS platforms, the customer receives a license for each.
Platform-scoped One license for just the selected platform.

Configuration

Configurations are org-level resources. Create them once and attach them to any product or platform. A platform-level config takes precedence over a product-level one.

Email: Mailgun

Launchday uses Mailgun to deliver license keys and waitlist notifications. Additional providers (SendGrid, Postmark, etc.) may be supported in future.

Setup

  1. 1 In your Mailgun dashboard, add and verify your sending domain (e.g. mail.yourapp.com)
  2. 2 Generate a sending API key (not the account key) in Mailgun → API Keys
  3. 3 In the Launchday dashboard, go to Settings → Email configs → New and fill in the fields below
  4. 4 Link the config to your product under Products → your product → Settings

Fields

Field Required Description
mailgun_api_key Your Mailgun sending API key. Stored encrypted at rest.
mailgun_domain Your verified Mailgun sending domain (e.g. mail.yourapp.com)
email_sender From address shown to recipients (e.g. Your App <hello@yourapp.com>)
email_reply_to Reply-to address for support replies
template_license_issued_html HTML email body for license delivery. Use {{.LicenseKey}}, {{.Email}}, {{.AppName}} as template variables.
template_license_issued_text Plain-text fallback for the license email. Same template variables apply.
API keys are encrypted with AES-256-GCM before being stored in the database and are never returned in API responses.

Storage

Launchday provides managed storage for your releases by default. You can also bring your own S3-compatible bucket — AWS S3, Backblaze B2, MinIO, and any other S3-compatible provider are supported.

Setup

  1. 1 Create a bucket in your storage provider and note the bucket name, region, and endpoint (if applicable)
  2. 2 Generate an access key/secret with read+write permissions on the bucket
  3. 3 Go to Settings → Storage configs → New in the dashboard and fill in the fields below
  4. 4 Link the config to your product under Products → your product → Settings

Fields

Field Required Description
provider s3 for AWS S3 or any S3-compatible provider
bucket Bucket name
region AWS region (e.g. us-east-1) or the provider's equivalent
endpoint_url Custom endpoint for non-AWS providers (e.g. Cloudflare R2: https://<account-id>.r2.cloudflarestorage.com). Leave blank for AWS S3.
path_prefix Optional key prefix for all uploads (e.g. launchday/)
credentials JSON object with access_key_id and secret_access_key. Stored encrypted at rest.
Credentials are encrypted with AES-256-GCM before storage and are never returned in API responses. Download URLs are presigned and expire after 15 minutes.

CLI

The launchday CLI turns shipping a release into a single command. Install it, run launchday init once, then use launchday build or launchday upload to publish.

CLI Reference