REST API Reference¶
Reference for the PyLocket REST API. All endpoints are prefixed with /v1/.
Base URL: https://api.pylocket.com/v1/
This page covers the developer-facing API. Admin-only and internal endpoints are not documented here.
Authentication¶
PyLocket accepts two credential types. Both identify the same developer account.
1. Bearer token (JWT) for interactive and portal-style use:
Obtain one from POST /v1/auth/login.
2. API key for machine-to-machine integrations (marketplace webhooks, scripts, CI):
Create or replace your key with POST /v1/auth/api-keys/rotate, or from the Developer Portal under Settings. The key is shown once, so store it immediately.
Send only one credential
The two schemes are evaluated in order: Authorization first, X-API-Key
second. If you send both and the JWT is expired or invalid, the request
fails with 401 and the API key is never tried. For server-to-server calls,
send X-API-Key on its own.
Auth Endpoints¶
POST /v1/auth/register¶
Register a new developer account.
Request Body:
Response (201):
{
"id": "dev_abc123",
"email": "dev@example.com",
"company_name": "My Company",
"created_at": "2026-03-01T00:00:00Z"
}
POST /v1/auth/login¶
Authenticate and receive JWT tokens.
Request Body:
Response (200) — without 2FA:
{
"access_token": "eyJhbGc...",
"refresh_token": "eyJhbGc...",
"token_type": "bearer",
"expires_in": 3600
}
Response (200) — with 2FA:
POST /v1/auth/login/verify-2fa¶
Complete login with a TOTP code.
Request Body:
Response (200):
{
"access_token": "eyJhbGc...",
"refresh_token": "eyJhbGc...",
"token_type": "bearer",
"expires_in": 3600
}
POST /v1/auth/refresh¶
Refresh an expired access token.
Request Body:
Response (200):
GET /v1/auth/me¶
Get the authenticated developer's profile.
Response (200):
{
"id": "dev_abc123",
"email": "dev@example.com",
"company_name": "My Company",
"two_factor_enabled": true,
"created_at": "2026-03-01T00:00:00Z"
}
POST /v1/auth/api-keys/rotate¶
Create an API key, or replace the existing one. Despite the name, this is also how you create your first key — there is no separate create endpoint, and calling it without a key already on the account is valid.
Any previous key stops working immediately.
api_key is returned once and never again: only a SHA-256 hash is stored,
so it cannot be recovered. Copy it when you see it. api_key_prefix is the
first 8 characters, kept so surfaces can show which key is active without
holding the secret.
Response (200):
{
"api_key": "5DHiPOIe7qUWnk1S2jt7T_g2Tu256T0wGIx6jKZX_nXtCXYUh_Sr4A-UVUMEaQc2",
"api_key_prefix": "5DHiPOIe"
}
POST /v1/auth/2fa/setup¶
Start 2FA setup. Returns a provisioning URI for authenticator apps.
Response (200):
{
"provisioning_uri": "otpauth://totp/PyLocket:dev@example.com?secret=...",
"backup_codes": ["abc123", "def456", "ghi789", "jkl012"]
}
POST /v1/auth/2fa/confirm¶
Confirm 2FA setup with a TOTP code.
Request Body:
Response (200):
POST /v1/auth/2fa/disable¶
Disable 2FA.
Request Body:
GET /v1/auth/2fa/status¶
Check 2FA status.
Response (200):
App Endpoints¶
GET /v1/apps¶
List all apps for the authenticated developer.
Response (200): paginated
{
"data": [
{
"id": "3f8a1c22-9b4e-4d17-a6f0-2c5e7d90b114",
"name": "MyApp",
"build_platforms": ["win-x64", "linux-x64"],
"obfuscation_level": "paranoid",
"created_at": "2026-03-01T00:00:00Z"
}
],
"meta": { "page": 1, "page_size": 20, "total": 1 }
}
build_platforms lists the distinct platforms of READY builds. Fields are
abridged; see the response of GET /v1/apps/{app_id} for the full shape.
POST /v1/apps¶
Create a new application.
Request Body:
platform_targets is optional and informational: the operative platform is
declared per build when you protect. python_versions (a list) is accepted the
same way.
Response (201):
{
"id": "3f8a1c22-9b4e-4d17-a6f0-2c5e7d90b114",
"name": "MyApp",
"platform_targets": ["win-x64", "linux-x64", "macos-arm64"],
"obfuscation_level": "paranoid",
"created_at": "2026-03-01T00:00:00Z"
}
GET /v1/apps/{app_id}¶
Get details for a specific app.
PATCH /v1/apps/{app_id}¶
Update an application. The method is PATCH (PUT returns 405). Platform targets are set at creation and are not updatable here.
Request Body:
DELETE /v1/apps/{app_id}¶
Delete an application and all associated builds and licenses.
Warning: This action is irreversible.
Build Endpoints¶
POST /v1/apps/{app_id}/builds¶
Create a new protection build by uploading an artifact.
Request: JSON body. Upload is a two-step flow: this call returns a
presigned upload_url; PUT the artifact bytes to it, then confirm the upload
via POST /v1/apps/{app_id}/builds/{build_id}/confirm.
| Field | Type | Description |
|---|---|---|
version |
String | Build version label (required) |
artifact_type |
String | e.g. exe, app, whl, zip, elf (required) |
platform |
String | Target platform, e.g. win-x64 (required) |
python_version |
String | Optional. Omit it and the protection engine detects the version from the artifact |
Response (201):
{
"id": "7c2d5e91-0a36-4b8f-9e14-8d63f2a70c55",
"app_id": "3f8a1c22-9b4e-4d17-a6f0-2c5e7d90b114",
"version": "1.0.0",
"status": "pending",
"upload_url": "https://...presigned...",
"created_at": "2026-03-01T00:00:00Z"
}
GET /v1/apps/{app_id}/builds¶
List all builds for an app.
Query Parameters:
| Parameter | Description |
|---|---|
status |
Filter by status |
limit |
Max results (default: 20) |
offset |
Pagination offset |
GET /v1/apps/{app_id}/builds/{build_id}¶
Get build details including status.
Response (200):
{
"id": "7c2d5e91-0a36-4b8f-9e14-8d63f2a70c55",
"app_id": "3f8a1c22-9b4e-4d17-a6f0-2c5e7d90b114",
"status": "ready",
"platform": "win-x64",
"python_version": "3.12",
"obfuscation_level": "paranoid",
"scan_status": "clean",
"protected_size_bytes": 2457600,
"protected_size_bytes": 3145728,
"created_at": "2026-03-01T00:00:00Z",
"completed_at": "2026-03-01T00:02:30Z"
}
GET /v1/apps/{app_id}/builds/{build_id}/download¶
Get a signed download URL for the protected artifact.
Response (200):
The download URL is valid for 7 days.
License Endpoints¶
How licenses get created
There is no generic POST /v1/licenses. A license is issued by whichever
distribution path you use, and each path has its own endpoint:
| You want to | Use | Charged |
|---|---|---|
| Generate a license key directly from your backend (marketplace webhook, custom storefront) | POST /v1/marketplace/licenses | $4 per license |
| Hand out codes in bulk that customers redeem later (AppSumo-style launch) | POST /v1/redemption/batches, redeemed via POST /v1/redeem | $4 on redemption; codes are free to create |
| Sell through PyLocket-managed Stripe Checkout | POST /v1/licenses/{license_id}/purchase-link, or an app Payment Link | $4 per sale |
| Give someone a direct download link | Direct links (see Distribute Your App) | $4 on claim |
| Offer a free trial | Trial links (see Free Trial Links) | Free |
| License a Shopify App Store subscription | The Partner Entitlement Webhook | $4 once per merchant, at purchase or trial conversion |
The endpoints below manage licenses that already exist.
POST /v1/licenses/activate¶
Activate an end-user license. Called by the protected application at runtime. Request and response details are handled automatically by the PyLocket runtime.
Error Responses:
| Status | Meaning |
|---|---|
401 |
Invalid or revoked license key |
403 |
Device limit exceeded |
429 |
Velocity limit exceeded (too many activations) |
POST /v1/licenses/refresh¶
Refresh a runtime token. Called periodically by the protected application. Request and response details are handled automatically by the PyLocket runtime.
GET /v1/licenses¶
List licenses for the authenticated developer.
Query Parameters:
| Parameter | Description |
|---|---|
app_id |
Filter by app |
status |
Filter by status: active, revoked, expired, demo |
limit |
Max results (default: 20) |
offset |
Pagination offset |
POST /v1/licenses/{license_id}/revoke¶
Revoke a license immediately. The license stops activating on all devices.
Response (200):
{
"id": "e7a51ccd-237b-46fc-86de-43f95e2e1115",
"status": "revoked",
"revoked_at": "2026-07-27T16:26:14.479123Z"
}
POST /v1/licenses/{license_id}/purchase-link¶
Create a Stripe Checkout URL so an end user can pay for an existing license. Requires an active Pro subscription, a connected Stripe account, and a Price ID configured on the app. See Stripe Connect Setup.
No request body. Response (200):
Error Responses:
| Status | Meaning |
|---|---|
400 |
The app has no Stripe Price ID configured |
403 |
Pro subscription required, or Stripe account not connected |
404 |
License not found |
Marketplace Endpoints¶
For selling through third-party marketplaces (AppSumo, Gumroad, Paddle, LemonSqueezy) or your own storefront. Full walkthrough: Marketplace Distribution.
POST /v1/marketplace/licenses¶
Generate a license key directly. Designed for machine-to-machine use: your backend calls this when a marketplace webhook reports a sale. This is the endpoint to use when you need a license key now, in the response.
Authentication: X-API-Key (recommended) or Authorization: Bearer.
Rate limit: 60 requests per minute.
Request:
{
"app_id": "15ed6311-6ef1-436e-8476-3bc287e44dfb",
"channel": "appsumo",
"customer_email": "buyer@example.com",
"marketplace_order_id": "ORDER-12345",
"license_config": {
"device_limit": 3,
"expiry_days": null,
"license_type": "perpetual"
}
}
| Field | Required | Notes |
|---|---|---|
app_id |
Yes | UUID of an app you own |
channel |
Yes | One of stripe, appsumo, gumroad, paddle, lemonsqueezy, shopify, manual, api |
customer_email |
No | Stored on the license for your records |
marketplace_order_id |
No | Your order reference, max 255 characters |
license_config.device_limit |
No | Default 3, range 1 to 10000 |
license_config.expiry_days |
No | null (default) means perpetual |
license_config.license_type |
No | perpetual (default), subscription, or trial |
Response (201):
{
"license_key": "AT6A-UPCT-1YNU-ONRR",
"license_id": "e7a51ccd-237b-46fc-86de-43f95e2e1115",
"app_name": "My App",
"download_url": "https://api.pylocket.com/v1/downloads/<token>",
"download_expires_at": "2026-08-01T12:30:00Z"
}
Store the license key when you receive it
license_key is returned only in this response. PyLocket stores a hash,
so the plaintext key cannot be retrieved later.
download_url is populated only when the app has a READY build with a protected
artifact; otherwise it is null. download_expires_at tells you when that link
stops working — mint a fresh license (or use the delivery page) after it lapses.
How your end-user activates — the key must reach the app
The download_url gives you the bare protected application, not a
key-injected installer. Your end-user needs the license_key and a way
to supply it to the app, or the app will show "the application encountered
a problem and needs to close" on launch. Any of these works, on every
engine version:
license-key.txtbeside the executable (simplest for automation): write a file namedlicense-key.txtcontaining the key in the same folder as the.exe(or, for a macOS.app, next to the innerContents/MacOS/<exe>). The runtime reads it automatically — no prompt.PYLOCKET_LICENSE_KEYenvironment variable set to the key before launch.- The PyLocket Installer / delivery page (
get.pylocket.com/download), which injectslicense-key.txtfor the end-user — the zero-touch path. - On-launch prompt — apps protected on the current engine also prompt for the key if none of the above is present. (Very old builds may not prompt reliably on windowed apps; supply the key via one of the methods above, or re-protect to pick up the current prompt.)
Error Responses:
| Status | Meaning |
|---|---|
401 |
Missing or invalid credentials |
403 |
Free-tier license limit reached (10) |
404 |
App not found, or not owned by you |
429 |
Rate limit exceeded |
Free-tier behavior
On the free tier every license is issued as a trial (is_trial=true),
defaulting to 30 days unless you supply license_config.expiry_days
(which is honored). license_config.license_type is ignored, so you cannot
issue a perpetual license on the free tier. You are also capped at 10
licenses total, and the cap counts revoked licenses. Upgrade to Pro to issue
perpetual licenses. The $4 license fee applies to Pro accounts.
No Stripe account required
This endpoint needs no Stripe Connect account and no Stripe account of your own. Sell through Gumroad, AppSumo, Paddle, your own storefront, or anywhere else; PyLocket bills you the $4 separately.
Redemption Endpoints¶
Generate codes in bulk now, and licenses are created only when customers redeem them. Creating codes is free; the $4 license fee applies at redemption. This is the usual choice for a marketplace launch where you must supply thousands of codes upfront.
POST /v1/redemption/batches¶
Create a batch of redemption codes.
Request:
{
"app_id": "15ed6311-6ef1-436e-8476-3bc287e44dfb",
"channel": "appsumo",
"quantity": 500,
"label": "AppSumo launch",
"license_config": {"device_limit": 3, "license_type": "perpetual"}
}
quantity ranges from 1 to 10000.
Response (201):
{
"batch_id": "...",
"channel": "appsumo",
"quantity": 500,
"codes": ["PLR-XXXXXXXX-XXXX", "..."],
"created_at": "2026-07-27T16:00:00Z"
}
Codes are returned once
Save the codes array immediately. PyLocket stores only a hash and an
8-character prefix, so the full codes cannot be recovered. The export
endpoint returns prefixes only.
GET /v1/redemption/batches¶
List your redemption batches.
GET /v1/redemption/batches/{batch_id}¶
Batch detail, including redemption counts.
GET /v1/redemption/batches/{batch_id}/codes¶
List code prefixes and their redemption status.
GET /v1/redemption/batches/{batch_id}/export¶
Export the batch as CSV. Contains code prefixes, not full codes.
POST /v1/redemption/batches/{batch_id}/revoke¶
Revoke all unredeemed codes in the batch.
POST /v1/redeem¶
Redeem a code and receive a license. No authentication (your customer calls this, typically from your redemption page). Rate limited to 5 requests per minute per IP.
Request:
Response (200):
{
"license_key": "AT6A-UPCT-1YNU-ONRR",
"license_id": "...",
"app_name": "My App",
"download_url": "https://...",
"download_expires_at": "2026-08-03T16:00:00Z"
}
Error Responses:
| Status | Meaning |
|---|---|
403 |
Developer's free-tier license limit reached |
404 |
Code not found |
409 |
Code already redeemed |
410 |
Code expired or revoked |
Partner Entitlement Webhook¶
License a subscription that lives on another platform (first supported partner channel: the Shopify App Store). Your service verifies the platform's webhooks, then forwards a normalized entitlement decision here; PyLocket grants, revokes, or reactivates the merchant's license to match. Full guide: Sell on Shopify.
POST /v1/public/partner-entitlements/{token}¶
Authentication: the per-app shared secret, sent in the
X-MerchantOps-Secret header (alias: X-PyLocket-Partner-Secret) and
verified in constant time. The {token} comes from the portal's
Sell on the Shopify App Store card, which also generates the secret.
The secret is shown once
The portal displays the shared secret exactly once, at generation. Store it as an encrypted secret in your service. Rotation is available anytime; after rotating, the previous secret keeps working for 24 hours.
Rate limit: 120 requests per minute per IP. Bodies over 16 KB are rejected.
Request:
{
"app": "your-app-slug",
"shop": "example.myshopify.com",
"topic": "app_subscriptions/update",
"status": "active",
"entitled": true,
"subscription_name": "Pro",
"event_id": "d9f8a1c2-...",
"occurred_at": "2026-08-16T10:00:00Z",
"trial": false,
"test": false,
"customer_email": "merchant@example.com"
}
entitled (JSON boolean), shop (a *.myshopify.com domain), event_id,
and occurred_at are required; app must match the product key configured
in the portal. trial: true events mint without the license fee (charged
once at conversion); test: true events run the full flow but never bill.
Response (200):
{
"ok": true,
"action": "granted",
"event_id": "d9f8a1c2-...",
"license_key": "XXXX-XXXX-XXXX-XXXX",
"delivery_url": "https://get.pylocket.com/d/...",
"note": null
}
action is one of granted, reactivated, revoked, noop,
duplicate, stale_skipped, unactionable. license_key and
delivery_url are present on grant-type results, including duplicates of
them, so retries are self-healing. A 200 is returned only after the
change is durably saved.
Error Responses:
| Status | Meaning |
|---|---|
400 |
Malformed JSON or invalid fields (permanent; do not retry) |
401 |
Missing or wrong shared secret (permanent; do not retry) |
404 |
Unknown token, or the webhook is disabled |
413 |
Body over 16 KB |
429 |
Rate limited; retry after the indicated delay |
503 |
The webhook is enabled but no secret is configured |
Retry only on 5xx, timeouts, and 429.
GET /v1/public/partner-entitlements/{token}/entitlement¶
Same authentication as the webhook. Query parameter: shop (the permanent
*.myshopify.com domain). Returns the shop's current entitlement, strictly
read-only:
{
"ok": true,
"shop": "example.myshopify.com",
"entitled": true,
"status": "active",
"trial": false,
"license_key": "XXXX-XXXX-XXXX-XXXX",
"delivery_url": "https://get.pylocket.com/d/...",
"expires_at": null
}
entitled folds in any trial expiry; a shop with no licence returns
entitled: false with null fields. 400 for a missing or malformed shop.
POST /v1/public/partner-entitlements/{token}/delivery-email¶
Body: {"shop": "...", "email": "merchant@example.com"} stores a
merchant-provided address on the shop's licence and sends the standard
delivery email. {"shop": "...", "email": null} clears it. Setting the
same, already-delivered address again returns {"action": "unchanged"}
without a resend. 404 when the shop has no licence.
POST /v1/public/partner-entitlements/{token}/trial¶
Body: {"shop": "...", "customer_email": "...", "test": true} (all but
shop optional). Mints a 14-day no-commitment trial licence, or echoes
the shop's current state when a licence already exists. The route is
idempotent by state — call it on every sign-in with any (or no)
event_id. Responses carry action: trial_granted or noop (with the
current state either way).
The shop is only the licence's identity: every trial rule is enforced at
the end user's computer, not the store. The trial's 14-day clock starts
at the app's first successful activation on a machine (until then the
licence reports trial: true with expires_at: null), each machine can
run a limited number of trials of the app (default one, whoever downloads
them), and store events never end a trial — uninstalling leaves the trial
running out its own clock, so reinstalling simply resumes it. The trial
is never billed; the first entitled: true webhook event without the
trial flag converts it, billing the one-time fee and lifting the trial
window. These three companion endpoints are synchronous broker calls:
treat 400/401/404/409 as permanent and alert.
Managing the integration¶
| Action | Endpoint |
|---|---|
| Enable (generates the URL token; generates the secret when none exists) | POST /v1/apps/{app_id}/partner-webhook with {"product_key": "..."} |
| Rotate the secret (returned once; old secret valid 24h) | POST /v1/apps/{app_id}/partner-webhook/rotate-secret |
| Disable event intake (URL and secret preserved) | DELETE /v1/apps/{app_id}/partner-webhook |
These use your normal developer authentication (X-API-Key or a session
token). Enable and rotate require a Pro subscription; disable does not, so
an integration can always be shut off.
Public Endpoints¶
GET /v1/public/pricing¶
Get public pricing information. No authentication required.
Response (200): amounts are in cents. Abridged; the live response also
includes example_fees[], features[], tagline, and cta_text for rendering
the pricing page.
{
"annual_subscription_price_cents": 900,
"annual_subscription_price_label": "$9.00/yr",
"license_fee_cents": 400,
"license_fee_label": "$4.00/license sold",
"free_build_download_limit": 10,
"storage_formula": "Base Fee + ceil(GB x S3 Rate x (1 + Margin) x 12 months)",
"s3_cost_per_gb_month_cents": 2.3,
"profit_margin_percent": 15.0,
"registration_enabled": true
}
Read prices from this endpoint, do not hard-code them
The annual subscription price changes over time (existing subscribers keep
the price they signed up at). Always read annual_subscription_price_cents
rather than embedding a number.
Error Response Format¶
All errors follow a consistent format:
{
"detail": "Human-readable error message",
"error_code": "INVALID_LICENSE_KEY",
"status_code": 401
}
Rate Limiting¶
All API endpoints are rate-limited. Rate limits vary by endpoint group (authentication, app management, build operations, license activation, and general API access).
Rate limit headers are included in all responses:
When the rate limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header.
Pagination¶
List endpoints support pagination:
| Parameter | Description | Default |
|---|---|---|
limit |
Max items per page | 20 |
offset |
Number of items to skip | 0 |
Response includes pagination metadata:
See Also¶
| Guide | Covers |
|---|---|
| Marketplace Distribution | Redemption codes and API provisioning, with Python and cURL examples |
| Stripe Connect Setup | Selling through PyLocket-managed Stripe Checkout |
| Distribute Your App | Direct links and delivery pages |
| Free Trial Links | Time-limited trial distribution |
| Billing | What each license path costs |