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.
Account changes need a signed-in session
An API key can do everything a build or licensing integration needs, but
it cannot change who controls the account. These endpoints accept only a
JWT access token from signing in, and return 403 with
"reason": "session_required" to an API key:
PATCH /v1/auth/profile,POST /v1/auth/change-password,POST /v1/auth/api-keys/rotatePOST /v1/auth/2fa/setup,/2fa/confirm,/2fa/disablePOST /v1/auth/cli/authorizePOST /v1/billing/cancel,POST /v1/billing/portal, and the Stripe Connect endpoints (GETandDELETE /v1/billing/stripe-connect,POST /v1/billing/stripe-connect/callback)
Changing the account email with PATCH /v1/auth/profile also needs your
current password in current_password; without it, or with a wrong one,
the answer is 403 with "reason": "reauth_required", as it is for a
wrong current_password on POST /v1/auth/change-password. The two
share a limit of 5 password attempts per 15 minutes (then 429 with
Retry-After). Name and business name changes need no password. After
an email change, a notice goes to the previous address, and any password
reset link sent to it stops working.
Reads such as GET /v1/auth/me and GET /v1/auth/2fa/status still accept
an API key. Only the access token from sign-in works as a Bearer token;
the refresh token is for POST /v1/auth/refresh only.
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 (or a backup code). The same step completes a
password reset on an account with 2FA: the reset saves the new password and
answers with an mfa_token instead of a session.
Each account gets 5 attempts per 15 minutes, from any address; after that the
answer is 429 with Retry-After.
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. The response includes delivery_mode
(latest_ready or released_only), which you change with
PATCH /v1/apps/{app_id}/delivery-mode.
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:
This endpoint does not change the delivery mode; a delivery_mode field in
its body is ignored.
PATCH /v1/apps/{app_id}/delivery-mode¶
Choose which builds your customers receive.
latest_ready(every app starts here): customers get the newest READY build for their platform, whether or not you released it.released_only: customers get only builds you released, still the newest released one for their platform. A new build stays private until you release it, and so does an update push: an app-wide push must name a released build.
Accepts a JWT or an API key.
Request Body:
| Field | Description |
|---|---|
delivery_mode |
latest_ready or released_only (required) |
release_current |
Only for a switch to released_only. true first releases, in the same change, the build each platform serves for download now (its newest READY build) and the build of your active update push, so every platform goes on serving the same build and your push stays offered. It releases nothing else: a link your customers already hold for an older build you have not released stops working at the switch (see Links already issued). Default false |
Unknown fields are refused with 422, so a misspelt release_current never
switches without releasing.
Response (200):
{
"app_id": "3f8a1c22-9b4e-4d17-a6f0-2c5e7d90b114",
"delivery_mode": "released_only",
"previous_mode": "latest_ready",
"changed": true,
"released_build_ids": ["7c2d5e91-0a36-4b8f-9e14-8d63f2a70c55"],
"serving": {"windows-x64": "7c2d5e91-0a36-4b8f-9e14-8d63f2a70c55"},
"platforms_without_release": [],
"newly_served": {}
}
| Field | Description |
|---|---|
changed |
false when the app was already in that mode (nothing was written) |
released_build_ids |
The builds release_current released |
serving |
The build each platform's customers receive after the call |
platforms_without_release |
In released_only: platforms that have a READY build but serve nothing, because none of their builds is released |
newly_served |
On a switch back to latest_ready: the platforms whose customers now get a different build (the newest READY one, released or not) |
Switching back to latest_ready is always allowed, and takes effect at once
for every platform: check newly_served to see what your customers now get.
Errors:
404if the app is not yours.409with"reason": "nothing_released"when switching toreleased_onlywith no released build and withoutrelease_current: every platform would have nothing to download. Release a build first, or send"release_current": true.409with"reason": "push_target_not_released"when switching toreleased_onlywhile your active update push names an unreleased build, withoutrelease_current. Release that build, send"release_current": true, or clear the push withDELETE /v1/apps/{app_id}/push-update.422for an unknown mode or field.429as for release: release, unrelease and this call share 30 calls a minute.
If the app has a build-event webhook,
each build release_current released sends it a build.released event.
In released_only, deleting the released build a platform serves (or its
protected artifact) returns 409 with "reason": "build_released": unrelease
it first.
Links already issued¶
In released_only, a link that names a build is checked each time it is
used, not only when it was issued: the download_url from
POST /v1/redeem (valid for 7 days), a direct download
link, a trial download link or a download token answers 404 while its
build is not released. release_current releases only the build each
platform serves now and your push target, so these links stop working at
the switch for every older build you have not released. If your customers
still hold links for an older build, release that build first (it stays
downloadable through those links, and each platform still serves its newest
released build). A signed file URL already handed out keeps working until
it expires (at most 7 days).
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 |
force_include |
Array of strings | Optional. Modules, packages or fully-qualified functions to protect even if the classifier would skip them. Omit to inherit the app default |
force_exclude |
Array of strings | Optional. Modules, packages or functions to never protect. Evaluated before force_include |
Module selection¶
By default PyLocket decides which modules are your application code and which
are dependencies. force_include overrides that decision for names you list.
Each entry is a package (myapp), a dotted module (myapp.services.billing),
or a fully-qualified function (myapp.data.ledger_key.ledger_key). Protection
is applied at module granularity, so naming a function protects its module and
additionally asserts that the function was protected: if it was not, the
build fails and names the entry. That is what makes a typo visible instead of
leaving you shipping in the belief that you were covered.
Omit the field to inherit the app's default; send [] to explicitly override
that default with nothing.
Naming a standard-library, PyInstaller-internal or known third-party package has no effect — those checks run before your declaration.
See Module Selection, which also covers
declaring the same lists in a pylocket.toml committed to your repo.
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",
"released_at": null
}
released_at is when you released the build (see
POST .../release), or null.
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.
POST /v1/apps/{app_id}/builds/{build_id}/release¶
Mark a build as released to your customers.
Whether a release changes what your customers receive depends on the app's
delivery mode. Every app starts in
latest_ready: customers get the newest READY build for their platform
whether or not you released it, so a release only records your decision. An
app in released_only gives customers only the builds you released (still
the newest one for their platform), so a new build stays private until you
release it.
Only a READY build with a protected artifact can be released. Releasing a
build that is already released changes nothing and answers "changed": false.
Accepts a JWT or an API key, so a CI job can release the build it just protected. There is no request body.
Response (200):
{
"build_id": "7c2d5e91-0a36-4b8f-9e14-8d63f2a70c55",
"app_id": "3f8a1c22-9b4e-4d17-a6f0-2c5e7d90b114",
"platform": "windows-x64",
"version": "1.2.0",
"released": true,
"released_at": "2026-10-01T09:30:00Z",
"changed": true,
"delivery_mode": "latest_ready",
"serving_build_id": "7c2d5e91-0a36-4b8f-9e14-8d63f2a70c55"
}
| Field | Description |
|---|---|
released |
Whether the build is released after the call |
released_at |
When it was released, or null |
changed |
false when the build was already in the requested state (nothing was written) |
delivery_mode |
latest_ready or released_only |
serving_build_id |
The build customers on this build's platform receive after the call, or null if none |
Errors:
404if the app is not yours or the build does not belong to it.409with"reason": "build_not_servable"if the build is not READY or has no protected artifact.429after 30 release, unrelease and delivery-mode calls in a minute (the three share the limit), withRetry-After.
If the app has a build-event webhook,
a change sends it a build.released event after the release is saved. A call
that answers "changed": false sends nothing.
POST /v1/apps/{app_id}/builds/{build_id}/unrelease¶
Undo a build's release. Works for a build in any status. Unreleasing a build
that is not released changes nothing and answers "changed": false. The
response has the same fields as release.
In released_only, unreleasing the only released build for a platform is
allowed: that platform then has nothing to download, and serving_build_id
is null. Customers who already downloaded the build keep it, and a signed
file URL already handed out keeps working until it expires (at most 7 days),
but a link that names the build answers 404 from then on (see
Links already issued).
Errors:
404and429as for release.409with"reason": "active_push_target"if the app is inreleased_onlyand the build is the target of the app's active update push. Clear the push first withDELETE /v1/apps/{app_id}/push-update, then unrelease.
A change sends an app with a build-event webhook a build.unreleased event.
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 a Stripe checkout PyLocket generates on your connected account | 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. For a trial, the trial length in days, at most 14 (the default is 14 on Pro) |
license_config.license_type |
No | perpetual (default), subscription, or trial. A trial license is a free trial: it is issued with is_trial=true, it is never charged the $4 fee, it lasts at most 14 days, one computer can use only one trial per app, and the app must have Allow free trials turned on (otherwise 403). expiry_days above 14 on a trial is rejected with 422 |
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 cannot make a free-tier
license perpetual (trial is honored on both tiers; the other values are
still issued as free-tier trials). 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 and is never
charged for a trial license (is_trial=true), whichever channel issued it.
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.
A licence in a subscription trial follows the subscription: any
entitled: false revokes it like a paid licence (note: "trial_ended";
none for a trial minted by a test: true event, which carries no trial
state).
Only a /trial licence (below) survives store events (noop,
note: "trial_unaffected_by_store").
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, or (with "note": "concurrent_mint_retry" and Retry-After) another event for the same shop was creating its licence at that moment: retry and it applies |
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 trial — uninstalling leaves
it running out its own clock, so reinstalling simply resumes it. (A
trial: true webhook event makes the licence a subscription trial, which
entitled: false does end.) 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 companion endpoints are synchronous broker calls:
treat 400/401/404/409 as permanent and alert.
On the three endpoints above, app (your product key, the same value the
webhook sends) is optional; on the entitlement read it is a query parameter.
The trial endpoint and the entitlement read refuse a mismatch with 409.
Delivery-email currently accepts a mismatch and records it. app is required
on the two endpoints below.
POST /v1/public/partner-entitlements/{token}/redact¶
For Shopify's shop/redact. Body: {"shop": "...", "app": "your-product-key"},
plus "force": true to redact a shop that is still paying or in a live
subscription trial.
What it does:
- The licence is kept, anonymised. The shop domain, any customer email, the Shopify attribution fields, any PyLocket Hub link and the Hub's installed-version records for the licence are removed.
- The licence becomes expired, whatever it was: paid, a subscription trial (it follows the subscription, so without the shop nothing could ever end it), a trial that already ended, or a revoked licence. An installed copy then gets a recoverable "expired" refusal and prompts for a key, so a merchant who reinstalls later can enter a new one. A developer's subscription renewal never brings it back.
- The one exception: an active trial from the
/trialendpoint stays active. It belongs to the computer, not the store, so it runs out its own clock. - Device fingerprint hashes are kept. They are one-way hashes that identify no one, and they enforce one free trial per computer.
- Delivery links, their download rows and every entitlement event for the shop are deleted.
The response is a receipt:
{
"ok": true,
"action": "redacted",
"shop": "example.myshopify.com",
"license_found": true,
"license_status": "expired",
"removed": {
"direct_links": 1,
"entitlements": 2,
"ledger_events": 7,
"customer_email": true,
"shop_identity": true,
"attribution_fields": ["customer_email", "partner_account", "shop_gid"],
"hub_link": false,
"hub_install_records": 0
},
"kept": [
{"item": "licence row", "why": "..."},
{"item": "device fingerprint hashes", "why": "..."},
{"item": "licence-fee billing record", "why": "..."},
{"item": "redaction record", "why": "..."},
{"item": "application log lines", "why": "..."}
]
}
A shop with an active paid licence returns 409 with
"reason": "shop_is_paying" unless force is true, and so does a shop in
a live subscription trial (the uninstall never reached us): Shopify sends
shop/redact 48 hours after uninstall, so a paying shop here usually means a
wiring mistake. Calling it again for the same shop returns a receipt with
"license_found": false and nothing removed. A redact is not reversible.
kept always lists the redaction record (that the request was processed,
with the counts; no shop domain or reference to it) and the application log
lines: lines written before the request can include the shop domain, and
they are deleted by the log stores' retention rather than edited. The
billing record appears only when a licence-fee event was written; it names
the licence, not the shop.
POST /v1/public/partner-entitlements/{token}/reset-devices¶
Body: {"shop": "...", "app": "your-product-key"}. Deactivates every
computer on the shop's licence so the merchant can activate on a new one.
{
"ok": true,
"action": "devices_reset",
"shop": "example.myshopify.com",
"deactivated_devices": 2,
"resets_remaining": 2,
"next_reset_available_at": null
}
Each licence can be reset 3 times in any 30 days. This allowance is separate
from the one you use in the portal. The fourth reset returns 429 with
"reason": "reset_limit_reached", resets_remaining: 0,
next_reset_available_at, and a Retry-After header in seconds. A reset
never gives a computer that has used a free trial of the app a second one.
404 when the shop has no licence; 409 with "reason": "license_not_active"
when its licence is not active.
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.
Example values. The annual subscription amount is penetration-priced and
changes monthly; always read it from the live response. The two
annual_subscription_* fields are shown as placeholders below:
annual_subscription_price_cents is an integer number of cents and
annual_subscription_price_label is a display string in the form $N.NN/yr.
{
"annual_subscription_price_cents": <current annual rate, in cents>,
"annual_subscription_price_label": "<current annual rate, formatted>",
"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¶
Every error the API raises has this shape (the HTTP status is the response's status code):
{
"error": {
"code": "http_error",
"message": "Human-readable error message",
"reason": "nothing_released"
},
"request_id": "7f1c2e9a-5b3d-4c1e-9a2f-0d6b8e4c1a37"
}
error.code:http_errorfor most refusals;validation_error(422) when the body or a parameter does not match the schema;rate_limit_exceeded(429) from the API-wide limit (see Rate Limiting);internal_erroranddata_integrity_error(500).error.message: the human-readable message. Show it; don't parse it.error.reason: only on refusals a client can act on. It is the machine-readable value this reference quotes as"reason": ..., for examplenothing_releasedorbuild_not_servable.error.purchase_url: on some licence and trial refusals, where the buyer can purchase.request_id: quote it when you contact support. Every response also carries it in theX-Request-IDheader; the API-wide 429 has it only there.
A path the API does not have returns 404, and a method a path does not
accept returns 405. Both come from the router before any endpoint runs, so
their body is {"detail": "Not Found"} or {"detail": "Method Not Allowed"}
instead.
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 |